Skip to content

Commit 325608b

Browse files
authored
Avoid applying map update callbacks to absent branches (#15248)
1 parent c4275e3 commit 325608b

2 files changed

Lines changed: 51 additions & 8 deletions

File tree

lib/elixir/lib/module/types/descr.ex

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3767,7 +3767,7 @@ defmodule Module.Types.Descr do
37673767
# If any of required or optional domains are satisfied, then we compute the
37683768
# initial return type. `map_update_keys_static` will then union into the
37693769
# computed type below, using the original bdd/dnf, not the one with updated domains.
3770-
descr = map_update_put_domains(bdd, domains, type_fun)
3770+
descr = map_update_put_domains(bdd, domains, type_fun, force?)
37713771
{remove_optional(value), descr, errors, true}
37723772
else
37733773
{remove_optional(value), none(), errors, false}
@@ -3920,31 +3920,42 @@ defmodule Module.Types.Descr do
39203920
:found_key -> true
39213921
end
39223922

3923+
# For each domain key, check if it exists in the map DNF and classify it
3924+
# as valid (matched) or invalid (missing). Accumulates the value type if require_type? is set.
3925+
# Returns {found?, valid_domains, invalid_domains, accumulated_value_type},
3926+
# where found? tracks whether at least one domain key was matched in the map.
39233927
defp map_update_get_domains(dnf, domain_keys, acc, require_type?, any_atom_key) do
39243928
Enum.reduce(domain_keys, {false, [], [], acc}, fn domain_key, {found?, valid, invalid, acc} ->
3929+
# Get the value type for this domain key, excluding optional entries
39253930
value = map_get_domain_no_optional(dnf, domain_key, none())
39263931

39273932
cond do
3933+
# Atom domains are special: we also check for individually named atom keys
39283934
domain_key == :atom ->
39293935
atom_acc = any_atom_key.()
39303936

39313937
cond do
3938+
# Domain has a direct match: valid, union both atom keys and domain value
39323939
not empty?(value) ->
39333940
acc = if require_type?, do: union(union(atom_acc, acc), value), else: acc
39343941
{true, [:atom | valid], invalid, acc}
39353942

3943+
# No direct match, but individual atom keys exist: found but domain is invalid
39363944
not empty?(atom_acc) ->
39373945
acc = if require_type?, do: union(atom_acc, acc), else: acc
39383946
{true, valid, [:atom | invalid], acc}
39393947

3948+
# No match at all
39403949
true ->
39413950
{found?, valid, [:atom | invalid], acc}
39423951
end
39433952

3953+
# Non-atom domain key has a match: mark as valid
39443954
not empty?(value) ->
39453955
acc = if require_type?, do: union(acc, value), else: acc
39463956
{true, [domain_key | valid], invalid, acc}
39473957

3958+
# Non-atom domain key not found: mark as invalid
39483959
true ->
39493960
{found?, valid, [domain_key | invalid], acc}
39503961
end
@@ -3974,24 +3985,29 @@ defmodule Module.Types.Descr do
39743985
# But that would not be helpful, as we can't distinguish between these two
39753986
# in Elixir code. It only makes sense to build the union for domain keys
39763987
# that do not exist.
3977-
defp map_update_put_domains(bdd, [], _type_fun), do: %{map: bdd}
3988+
defp map_update_put_domains(bdd, [], _type_fun, _force?), do: %{map: bdd}
39783989

3979-
defp map_update_put_domains(bdd, domain_keys, type_fun) do
3990+
defp map_update_put_domains(bdd, domain_keys, type_fun, force?) do
39803991
bdd =
39813992
bdd_map(bdd, fn {tag, fields} ->
3982-
{map_update_put_domain(tag, domain_keys, type_fun), fields}
3993+
{map_update_put_domain(tag, domain_keys, type_fun, force?), fields}
39833994
end)
39843995

39853996
%{map: bdd}
39863997
end
39873998

3988-
defp map_update_put_domain(tag_or_domains, domain_keys, type_fun) do
3999+
defp map_update_put_domain(tag_or_domains, domain_keys, type_fun, force?) do
39894000
case tag_or_domains do
39904001
:open ->
39914002
:open
39924003

39934004
:closed ->
3994-
fields_from_keys(domain_keys, if_set(type_fun.(true, none())))
4005+
# Non-forced updates must not invoke the callback on absent branches:
4006+
# the callback may itself typecheck a function application, and
4007+
# applying it to `none()` will raise undue warnings.
4008+
if force?,
4009+
do: fields_from_keys(domain_keys, if_set(type_fun.(true, none()))),
4010+
else: :closed
39954011

39964012
# Note: domain_keys may contain duplicates, so we cannot
39974013
# do a side-by-side traversal here.
@@ -4002,7 +4018,10 @@ defmodule Module.Types.Descr do
40024018
fields_store(domain_key, union(value, type_fun.(true, remove_optional(value))), acc)
40034019

40044020
:error ->
4005-
fields_store(domain_key, if_set(type_fun.(true, none())), acc)
4021+
# Likewise, only forced updates may synthesize missing domain keys.
4022+
if force?,
4023+
do: fields_store(domain_key, if_set(type_fun.(true, none())), acc),
4024+
else: acc
40064025
end
40074026
end)
40084027
end
@@ -4079,7 +4098,7 @@ defmodule Module.Types.Descr do
40794098
descr =
40804099
case required_domains ++ optional_domains do
40814100
[] -> none()
4082-
domains -> map_update_put_domains(bdd, domains, type_fun)
4101+
domains -> map_update_put_domains(bdd, domains, type_fun, true)
40834102
end
40844103

40854104
dnf = map_bdd_to_dnf_with_empty(bdd)

lib/elixir/test/elixir/module/types/descr_test.exs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2276,6 +2276,30 @@ defmodule Module.Types.DescrTest do
22762276
|> map_update(atom([:b]), integer(), true, true) == {none(), none(), []}
22772277
end
22782278

2279+
test "with non-empty open maps does not call the callback with none from absent branches" do
2280+
# This is a test of the map_update_fun/5 with forced?: false parameter.
2281+
# We check that it does not call its typed_fun argument with `none()`
2282+
# due to the key being absent in the map.
2283+
2284+
type = dynamic(difference(open_map(), empty_map()))
2285+
ref = make_ref()
2286+
2287+
fun = fn _optional?, value ->
2288+
send(self(), {ref, value})
2289+
value
2290+
end
2291+
2292+
_ = map_update_fun(type, binary(), fun, false, false)
2293+
2294+
messages = Process.info(self(), :messages) |> elem(1)
2295+
2296+
# Check that the callback was not invoked with `none()`
2297+
refute Enum.any?(messages, fn
2298+
{seen_ref, value} when seen_ref == ref -> empty?(value)
2299+
_ -> false
2300+
end)
2301+
end
2302+
22792303
test "with dynamic atom keys" do
22802304
assert map_update(closed_map(key: atom([:value])), dynamic(), atom([:new_value])) ==
22812305
{atom([:value]), closed_map(key: atom([:value, :new_value])), []}

0 commit comments

Comments
 (0)