Skip to content

Use a plain map for Ecto.Multi names to avoid Dialyzer opaqueness violations - #4800

Closed
roelandvanbatenburg wants to merge 1 commit into
elixir-ecto:masterfrom
roelandvanbatenburg:multi-names-plain-map
Closed

roelandvanbatenburg wants to merge 1 commit into
elixir-ecto:masterfrom
roelandvanbatenburg:multi-names-plain-map

Conversation

@roelandvanbatenburg

Copy link
Copy Markdown

Summary

Ecto.Multi.new() still triggers call_without_opaque on Elixir 1.20.4 / Erlang/OTP 29.1, so #4707 isn't fully fixed by the Elixir 1.20 MapSet change. Elixir has since documented @dialyzer :no_opaque as the workaround instead of fixing it further (elixir-lang/elixir#15673, #15674), which would mean every Ecto.Multi caller has to add it.

The cause: defstruct names: MapSet.new() is inlined as a %MapSet{map: %{}} literal. Dialyzer infers the opaque :sets.set/1 for MapSet internals from the :sets calls in MapSet.put/2 / MapSet.member?/2, which flows into the success typing of Ecto.Multi.update/3 and friends.

This replaces the internal MapSet with a plain map (similar to #4708):

-  defstruct operations: [], names: MapSet.new()
+  defstruct operations: [], names: %{}

-  @typep names :: MapSet.t()
+  @typep names :: %{optional(name) => true}

 add_operation
-  MapSet.member?(names, name) / MapSet.put(names, name)
+  Map.has_key?(names, name)   / Map.put(names, name, true)

 merge_structs / merge_results
-  MapSet.intersection |> MapSet.to_list, MapSet.union
+  common_names/2,                        Map.merge

Evidence

Minimal repro without Ecto, on Elixir 1.20.4 / OTP 29.1:

defmodule Holder do
  defstruct names: MapSet.new()
  @type t :: %__MODULE__{names: MapSet.t()}
  @spec new() :: t
  def new, do: %__MODULE__{}
  @spec add(t, term) :: t
  def add(%__MODULE__{names: names} = h, name), do: %{h | names: MapSet.put(names, name)}
end

Holder.add(Holder.new(), :x)                          # call_without_opaque
Holder.add(%Holder{names: MapSet.new(list)}, :x)      # ok (runtime value, not inlined)

Downstream app calling Ecto.Multi.new() |> Ecto.Multi.update(:user, changeset):

  • Before: lib/my_app/accounts.ex:236:23:call_without_opaque ... Call does not have expected term of type %Ecto.Multi{names: %MapSet{map: :sets.set(_)}, ...} (with opaque subterms)
    After: Total errors: 0 — done (passed successfully)

mix test on Elixir 1.20.4 / OTP 29.1: 1609 passed (97 doctests, 1512 tests). The multi.names assertions in test/ecto/multi_test.exs are updated to the map shape.

Merge Danger

Door: two-way

Blast Radius: small

names is an internal field (@typep). Only code that reads multi.names directly and expects a MapSet would notice; nothing in ecto_sql does.

…lations

The names field defaulted to MapSet.new(), which the compiler inlines as a
%MapSet{map: %{}} literal. On Erlang/OTP 29 (and 28), Dialyzer infers the
opaque :sets.set/1 type for MapSet's internals from the :sets calls in
MapSet.put/2 and MapSet.member?/2, and reports call_without_opaque for any
caller passing Ecto.Multi.new() into another Ecto.Multi function. Elixir
documents @dialyzer :no_opaque as the workaround (elixir-lang/elixir#15674),
which every Ecto.Multi user would have to add.

Using a map keyed by operation name keeps the same semantics without the
opaque type.
@roelandvanbatenburg
roelandvanbatenburg deleted the multi-names-plain-map branch October 15, 2026 00:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant