Skip to content

Commit d7f8578

Browse files
authored
Add map normalization cases (#13997)
1 parent 5c88118 commit d7f8578

File tree

2 files changed

+123
-9
lines changed

2 files changed

+123
-9
lines changed

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,7 +1252,7 @@ defmodule Module.Types.Descr do
12521252
defp map_only?(descr), do: empty?(Map.delete(descr, :map))
12531253

12541254
# Union is list concatenation
1255-
defp map_union(dnf1, dnf2), do: dnf1 ++ dnf2
1255+
defp map_union(dnf1, dnf2), do: dnf1 ++ (dnf2 -- dnf1)
12561256

12571257
# Given two unions of maps, intersects each pair of maps.
12581258
defp map_intersection(dnf1, dnf2) do
@@ -1682,15 +1682,47 @@ defmodule Module.Types.Descr do
16821682
end
16831683

16841684
# Use heuristics to normalize a map dnf for pretty printing.
1685-
# TODO: Eliminate some simple negations, those which have only zero or one key in common.
16861685
defp map_normalize(dnf) do
16871686
dnf
16881687
|> Enum.reject(&map_empty?([&1]))
16891688
|> Enum.map(fn {tag, fields, negs} ->
1690-
{tag, fields, Enum.reject(negs, &map_empty_negation?(tag, fields, &1))}
1689+
{fields, negs} =
1690+
Enum.reduce(negs, {fields, []}, fn neg = {neg_tag, neg_fields}, {acc_fields, acc_negs} ->
1691+
if map_empty_negation?(tag, acc_fields, neg) do
1692+
{acc_fields, acc_negs}
1693+
else
1694+
case all_but_one?(tag, acc_fields, neg_tag, neg_fields) do
1695+
{:one, diff_key} ->
1696+
{Map.update!(acc_fields, diff_key, &difference(&1, neg_fields[diff_key])),
1697+
acc_negs}
1698+
1699+
_ ->
1700+
{acc_fields, [neg | acc_negs]}
1701+
end
1702+
end
1703+
end)
1704+
1705+
{tag, fields, negs}
16911706
end)
16921707
end
16931708

1709+
# If all fields are the same except one, we can optimize map difference.
1710+
defp all_but_one?(tag1, fields1, tag2, fields2) do
1711+
keys1 = Map.keys(fields1)
1712+
keys2 = Map.keys(fields2)
1713+
1714+
if {tag1, tag2} == {:open, :closed} or
1715+
:sets.from_list(keys1, version: 2) != :sets.from_list(keys2, version: 2) do
1716+
:no
1717+
else
1718+
Enum.count(keys1, fn key -> Map.get(fields1, key) != Map.get(fields2, key) end)
1719+
|> case do
1720+
1 -> {:one, Enum.find(keys1, &(Map.get(fields1, &1) != Map.get(fields2, &1)))}
1721+
_ -> :no
1722+
end
1723+
end
1724+
end
1725+
16941726
# Adapted from `map_empty?` to remove useless negations.
16951727
defp map_empty_negation?(tag, fields, {neg_tag, neg_fields}) do
16961728
(tag == :closed and

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

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,11 @@ defmodule Module.Types.DescrTest do
12631263

12641264
assert tuple([closed_map(a: integer()), open_map()]) |> to_quoted_string() ==
12651265
"{%{a: integer()}, %{...}}"
1266+
1267+
# TODO: eliminate tuple differences
1268+
# assert difference(tuple([number(), term()]), tuple([integer(), atom()]))
1269+
# |> to_quoted_string() ==
1270+
# "{float(), term()} or {number(), term() and not atom()}"
12661271
end
12671272

12681273
test "map" do
@@ -1278,9 +1283,8 @@ defmodule Module.Types.DescrTest do
12781283
assert open_map("Elixir.Foo.Bar": float()) |> to_quoted_string() ==
12791284
"%{..., Foo.Bar => float()}"
12801285

1281-
# TODO: support this simplification
1282-
# assert difference(open_map(), open_map(a: term())) |> to_quoted_string() ==
1283-
# "%{..., a: not_set()}"
1286+
assert difference(open_map(), open_map(a: term())) |> to_quoted_string() ==
1287+
"%{..., a: not_set()}"
12841288

12851289
assert closed_map(a: integer(), b: atom()) |> to_quoted_string() ==
12861290
"%{a: integer(), b: atom()}"
@@ -1295,13 +1299,40 @@ defmodule Module.Types.DescrTest do
12951299
assert closed_map(foo: union(integer(), not_set())) |> to_quoted_string() ==
12961300
"%{foo: if_set(integer())}"
12971301

1298-
assert difference(open_map(a: integer()), closed_map(b: boolean())) |> to_quoted_string() ==
1299-
"%{..., a: integer()}"
1300-
1302+
# Test normalization
13011303
assert open_map(a: integer(), b: atom())
13021304
|> difference(open_map(b: atom()))
13031305
|> union(open_map(a: integer()))
13041306
|> to_quoted_string() == "%{..., a: integer()}"
1307+
1308+
assert union(open_map(a: integer()), open_map(a: integer())) |> to_quoted_string() ==
1309+
"%{..., a: integer()}"
1310+
1311+
assert difference(open_map(a: number(), b: atom()), open_map(a: integer()))
1312+
|> to_quoted_string() == "%{..., a: float(), b: atom()}"
1313+
1314+
# Test complex combinations
1315+
assert intersection(open_map(a: number(), b: atom()), open_map(a: integer(), c: boolean()))
1316+
|> union(difference(open_map(x: atom()), open_map(x: boolean())))
1317+
|> to_quoted_string() ==
1318+
"%{..., a: integer(), b: atom(), c: boolean()} or %{..., x: atom() and not boolean()}"
1319+
1320+
assert closed_map(a: number(), b: atom(), c: pid())
1321+
|> difference(closed_map(a: integer(), b: atom(), c: pid()))
1322+
|> to_quoted_string() == "%{a: float(), b: atom(), c: pid()}"
1323+
1324+
# No simplification compared to above, as it is an open map
1325+
assert open_map(a: number(), b: atom())
1326+
|> difference(closed_map(a: integer(), b: atom()))
1327+
|> to_quoted_string() ==
1328+
"%{..., a: float() or integer(), b: atom()} and not %{a: integer(), b: atom()}"
1329+
1330+
# Remark: this simplification is order dependent. Having the first difference
1331+
# after the second gives a different result.
1332+
assert open_map(a: number(), b: atom(), c: union(pid(), port()))
1333+
|> difference(open_map(a: float(), b: atom(), c: pid()))
1334+
|> difference(open_map(a: integer(), b: atom(), c: union(pid(), port())))
1335+
|> to_quoted_string() == "%{..., a: float(), b: atom(), c: port()}"
13051336
end
13061337

13071338
test "structs" do
@@ -1344,5 +1375,56 @@ defmodule Module.Types.DescrTest do
13441375
assert subtype?(descr1, descr2)
13451376
refute subtype?(descr2, descr1)
13461377
end
1378+
1379+
test "map difference" do
1380+
# Create a large map with various types
1381+
map1 =
1382+
open_map([
1383+
{:id, integer()},
1384+
{:name, binary()},
1385+
{:age, union(integer(), atom())},
1386+
{:email, binary()},
1387+
{:active, boolean()},
1388+
{:tags, list(atom())}
1389+
])
1390+
1391+
# Create another large map with some differences and many more entries
1392+
map2 =
1393+
open_map(
1394+
[
1395+
{:id, integer()},
1396+
{:name, binary()},
1397+
{:age, integer()},
1398+
{:email, binary()},
1399+
{:active, boolean()},
1400+
{:tags, non_empty_list(atom())},
1401+
{:meta,
1402+
open_map([
1403+
{:created_at, binary()},
1404+
{:updated_at, binary()},
1405+
{:status, atom()}
1406+
])},
1407+
{:permissions, tuple([atom(), integer(), atom()])},
1408+
{:profile,
1409+
open_map([
1410+
{:bio, binary()},
1411+
{:interests, non_empty_list(binary())},
1412+
{:social_media,
1413+
open_map([
1414+
{:twitter, binary()},
1415+
{:instagram, binary()},
1416+
{:linkedin, binary()}
1417+
])}
1418+
])},
1419+
{:notifications, boolean()}
1420+
] ++
1421+
Enum.map(1..50, fn i ->
1422+
{:"field_#{i}", atom([:"value_#{i}"])}
1423+
end)
1424+
)
1425+
1426+
refute subtype?(map1, map2)
1427+
assert subtype?(map2, map1)
1428+
end
13471429
end
13481430
end

0 commit comments

Comments
 (0)