Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 62 additions & 12 deletions lib/elixir/lib/module/types/expr.ex
Original file line number Diff line number Diff line change
Expand Up @@ -181,18 +181,19 @@ defmodule Module.Types.Expr do
{{key_type, value_type}, context}
end)

# The only information we can attach to the expected types is that
# certain keys are expected. And we can only do so if the key has
# no other components, even dynamic ones (hence upper bound).
expected_pairs =
Enum.flat_map(pairs_types, fn {key_type, _value_type} ->
case atom_fetch(upper_bound(key_type)) do
{:finite, [key]} -> [{key, {term(), false}}]
_ -> []
end
end)

expected = opt_intersection(expected, open_map(expected_pairs))
# The expected type describes the result of the update, not the
# original map. Fields that are replaced by the update need to exist
# in the original map, but their old values are not constrained by
# what the consumer expects of the new values. Fields that are not
# replaced keep whatever constraint the consumer has on them.
#
# A key that is a single atom must exist in the original map. A key
# that is a finite set of atoms may replace any of those fields, so
# their constraints are erased without requiring them to exist. Any
# other key (infinite atoms or non-atom domains) may replace any field,
# so all value constraints are dropped and only the statically known
# keys are kept as required.
expected = map_update_expected(pairs_types, expected)
{map_type, context} = of_expr(map, expected, expr, stack, context)

try do
Expand Down Expand Up @@ -927,6 +928,55 @@ defmodule Module.Types.Expr do

## General helpers

# Computes the expected type of the map being updated in %{map | ...}
# from the expected type of the update result. See the map update
# clause in of_expr/5 for the rationale.
defp map_update_expected(pairs_types, expected) do
keys =
Enum.reduce_while(pairs_types, [], fn {key_type, _value_type}, acc ->
case atom_fetch(upper_bound(key_type)) do
{:finite, [key]} -> {:cont, [{:required, key} | acc]}
{:finite, keys} -> {:cont, [{:optional, keys} | acc]}
_ -> {:halt, :unknown}
end
end)

with keys when keys != :unknown <- keys,
{:ok, result} <- erase_updated_keys(keys, opt_intersection(expected, open_map())) do
result
else
_ -> open_map(map_update_required_keys(pairs_types))
end
end

defp erase_updated_keys(keys, acc) do
Enum.reduce_while(keys, {:ok, acc}, fn
{:required, key}, {:ok, acc} ->
# term() erases the constraint but still requires the key to exist
case map_put_key(acc, key, term()) do
{:ok, acc} -> {:cont, {:ok, acc}}
:badmap -> {:halt, :badmap}
end

{:optional, keys}, {:ok, acc} ->
# Any of the keys may be replaced, so erase all of them without
# asserting the existence of any particular one
case map_update(acc, atom(keys), term(), true, false, true) do
{_type, acc, _errors} -> {:cont, {:ok, acc}}
_ -> {:halt, :badmap}
end
end)
end

defp map_update_required_keys(pairs_types) do
Enum.flat_map(pairs_types, fn {key_type, _value_type} ->
case atom_fetch(upper_bound(key_type)) do
{:finite, [key]} -> [{key, {term(), false}}]
_ -> []
end
end)
end

defp apply_many([], fun, args, expected, expr, stack, context) do
Apply.remote(fun, args, expected, expr, stack, context, &of_expr/5)
end
Expand Down
81 changes: 81 additions & 0 deletions lib/elixir/test/elixir/module/types/expr_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,87 @@ defmodule Module.Types.ExprTest do
"""
end

test "updating maps does not constrain the replaced values" do
# The replaced key must exist but its old value is unconstrained
assert typecheck!(
[x],
(
%{a: 1} = %{x | a: 1}
x
)
) ==
dynamic(open_map(a: {term(), false}))

assert typecheck!(
[x],
(
%{x | a: 1}.a + 1
x
)
) ==
dynamic(open_map(a: {term(), false}))

assert typecheck!(
[x],
(
%{a: 1, b: 2} = %{x | a: 1, b: 2}
x
)
) ==
dynamic(open_map(a: {term(), false}, b: {term(), false}))

# Constraints on fields that are not replaced still propagate
assert typecheck!(
[x],
(
%{a: 1, b: 2} = %{x | a: 1}
x
)
) ==
dynamic(open_map(a: {term(), false}, b: {integer(), false}))

assert typecheck!(
[x],
(
%{x | a: 1}.b + 1
x
)
) ==
dynamic(open_map(a: {term(), false}, b: {opt_union(integer(), float()), false}))

# Finite computed keys erase only their fields and require none of them
assert typecheck!(
[x],
(
key = if :rand.uniform() > 0.5, do: :a, else: :b
%{a: 1, c: 2} = %{x | key => 1}
x
)
)
|> equal?(dynamic(open_map(c: {integer(), false})))

assert typecheck!(
[x],
(
key = if :rand.uniform() > 0.5, do: :a, else: :b
%{x | key => 1, c: 2}.c + 1
x
)
)
|> equal?(dynamic(open_map(c: {term(), false})))

# Unbounded computed keys may replace any field, so no value constraint propagates
assert typecheck!(
[x, key],
is_atom(key),
(
%{a: 1, b: 2} = %{x | key => 1}
x
)
) ==
dynamic(open_map())
end

test "updating maps with mixed record/dictionary keys" do
# Static keys
assert typecheck!(
Expand Down