Skip to content

Commit 6988fba

Browse files
committed
Avoid applying map update callbacks to absent branches
1 parent 5d794ab commit 6988fba

2 files changed

Lines changed: 48 additions & 8 deletions

File tree

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3650,6 +3650,7 @@ defmodule Module.Types.Descr do
36503650
However, the third argument is an anonymous function that receives the current
36513651
value and returns `type_fun`. Note the value returned by `type_fun` cannot hold
36523652
dynamic. Any dynamic conversion must happen before invoking this function.
3653+
36533654
"""
36543655
def map_update_fun(descr, key_descr, type_fun, return_type? \\ true, force? \\ false) do
36553656
gradual? = gradual?(descr)
@@ -3766,7 +3767,7 @@ defmodule Module.Types.Descr do
37663767
# If any of required or optional domains are satisfied, then we compute the
37673768
# initial return type. `map_update_keys_static` will then union into the
37683769
# computed type below, using the original bdd/dnf, not the one with updated domains.
3769-
descr = map_update_put_domains(bdd, domains, type_fun)
3770+
descr = map_update_put_domains(bdd, domains, type_fun, force?)
37703771
{remove_optional(value), descr, errors, true}
37713772
else
37723773
{remove_optional(value), none(), errors, false}
@@ -3919,31 +3920,42 @@ defmodule Module.Types.Descr do
39193920
:found_key -> true
39203921
end
39213922

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.
39223927
defp map_update_get_domains(dnf, domain_keys, acc, require_type?, any_atom_key) do
39233928
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
39243930
value = map_get_domain_no_optional(dnf, domain_key, none())
39253931

39263932
cond do
3933+
# Atom domains are special: we also check for individually named atom keys
39273934
domain_key == :atom ->
39283935
atom_acc = any_atom_key.()
39293936

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

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

3948+
# No match at all
39393949
true ->
39403950
{found?, valid, [:atom | invalid], acc}
39413951
end
39423952

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

3958+
# Non-atom domain key not found: mark as invalid
39473959
true ->
39483960
{found?, valid, [domain_key | invalid], acc}
39493961
end
@@ -3973,24 +3985,27 @@ defmodule Module.Types.Descr do
39733985
# But that would not be helpful, as we can't distinguish between these two
39743986
# in Elixir code. It only makes sense to build the union for domain keys
39753987
# that do not exist.
3976-
defp map_update_put_domains(bdd, [], _type_fun), do: %{map: bdd}
3988+
defp map_update_put_domains(bdd, [], _type_fun, _force?), do: %{map: bdd}
39773989

3978-
defp map_update_put_domains(bdd, domain_keys, type_fun) do
3990+
defp map_update_put_domains(bdd, domain_keys, type_fun, force?) do
39793991
bdd =
39803992
bdd_map(bdd, fn {tag, fields} ->
3981-
{map_update_put_domain(tag, domain_keys, type_fun), fields}
3993+
{map_update_put_domain(tag, domain_keys, type_fun, force?), fields}
39823994
end)
39833995

39843996
%{map: bdd}
39853997
end
39863998

3987-
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
39884000
case tag_or_domains do
39894001
:open ->
39904002
:open
39914003

39924004
:closed ->
3993-
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?, do: fields_from_keys(domain_keys, if_set(type_fun.(true, none()))), else: :closed
39944009

39954010
# Note: domain_keys may contain duplicates, so we cannot
39964011
# do a side-by-side traversal here.
@@ -4001,7 +4016,8 @@ defmodule Module.Types.Descr do
40014016
fields_store(domain_key, union(value, type_fun.(true, remove_optional(value))), acc)
40024017

40034018
:error ->
4004-
fields_store(domain_key, if_set(type_fun.(true, none())), acc)
4019+
# Likewise, only forced updates may synthesize missing domain keys.
4020+
if force?, do: fields_store(domain_key, if_set(type_fun.(true, none())), acc), else: acc
40054021
end
40064022
end)
40074023
end
@@ -4078,7 +4094,7 @@ defmodule Module.Types.Descr do
40784094
descr =
40794095
case required_domains ++ optional_domains do
40804096
[] -> none()
4081-
domains -> map_update_put_domains(bdd, domains, type_fun)
4097+
domains -> map_update_put_domains(bdd, domains, type_fun, true)
40824098
end
40834099

40844100
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
@@ -2275,6 +2275,30 @@ defmodule Module.Types.DescrTest do
22752275
|> map_update(atom([:b]), integer(), true, true) == {none(), none(), []}
22762276
end
22772277

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

0 commit comments

Comments
 (0)