From 60225a9257d32c67b8bfa04238b47966aefd2267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ey=C3=BCp=20Can=20Akman?= Date: Thu, 19 Mar 2026 13:25:45 +0300 Subject: [PATCH] feat(builtin): add merge() for combining maps Add a variadic merge() builtin that shallow-merges two or more maps. Later maps override earlier ones (last-write-wins). Returns a new map without modifying inputs. Keys keep their original type, so the result is map[any]any, the same type fromPairs() returns. The copy loop uses MapRange rather than MapKeys plus MapIndex, because MapIndex looks a key up by equality and so drops any key that is not equal to itself, such as NaN. Fixes #895 --- builtin/builtin.go | 40 +++++++++++++++++++ builtin/builtin_test.go | 11 ++++++ docs/language-definition.md | 8 ++++ test/fuzz/fuzz_test.go | 1 + test/issues/895/issue_test.go | 74 +++++++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+) create mode 100644 test/issues/895/issue_test.go diff --git a/builtin/builtin.go b/builtin/builtin.go index 87e73614a..398f64485 100644 --- a/builtin/builtin.go +++ b/builtin/builtin.go @@ -806,6 +806,46 @@ var Builtins = []*Function{ return anyType, fmt.Errorf("cannot transform %s from pairs", args[0]) }, }, + { + Name: "merge", + Safe: func(args ...any) (any, uint, error) { + if len(args) < 2 { + return nil, 0, fmt.Errorf("invalid number of arguments (expected at least 2, got %d)", len(args)) + } + + out := reflect.MakeMap(mapType) + + for _, arg := range args { + v := reflect.ValueOf(arg) + + if v.Kind() != reflect.Map { + return nil, 0, fmt.Errorf("cannot merge %s", v.Kind()) + } + + iter := v.MapRange() + for iter.Next() { + out.SetMapIndex(iter.Key(), iter.Value()) + } + } + + return out.Interface(), uint(out.Len()), nil + }, + Validate: func(args []reflect.Type) (reflect.Type, error) { + if len(args) < 2 { + return anyType, fmt.Errorf("invalid number of arguments (expected at least 2, got %d)", len(args)) + } + + for _, arg := range args { + switch kind(arg) { + case reflect.Interface, reflect.Map: + default: + return anyType, fmt.Errorf("cannot merge %s", arg) + } + } + + return mapType, nil + }, + }, { Name: "reverse", Safe: func(args ...any) (any, uint, error) { diff --git a/builtin/builtin_test.go b/builtin/builtin_test.go index 0d0dec357..c3739343f 100644 --- a/builtin/builtin_test.go +++ b/builtin/builtin_test.go @@ -197,6 +197,12 @@ func TestBuiltin(t *testing.T) { {`flatten([["a", "b"], [1, 2, [3, [[[["c", "d"], "e"]]], 4]]])`, []any{"a", "b", 1, 2, 3, "c", "d", "e", 4}}, {`uniq([1, 15, "a", 2, 3, 5, 2, "a", 2, "b"])`, []any{1, 15, "a", 2, 3, 5, "b"}}, {`uniq([[1, 2], "a", 2, 3, [1, 2], [1, 3]])`, []any{[]any{1, 2}, "a", 2, 3, []any{1, 3}}}, + {`merge({"a": 1}, {"b": 2})`, map[any]any{"a": 1, "b": 2}}, + {`merge({"a": 1, "b": 2}, {"b": 3})`, map[any]any{"a": 1, "b": 3}}, + {`merge({"a": 1}, {"b": 2}, {"c": 3})`, map[any]any{"a": 1, "b": 2, "c": 3}}, + {`merge({"a": 1}, {"a": 2})`, map[any]any{"a": 2}}, + {`merge({}, {"a": 1})`, map[any]any{"a": 1}}, + {`merge({"a": 1}, {})`, map[any]any{"a": 1}}, } for _, test := range tests { @@ -219,6 +225,7 @@ func TestBuiltin_works_with_any(t *testing.T) { "get": {2}, "take": {2}, "sortBy": {2}, + "merge": {2}, } for _, b := range builtin.Builtins { @@ -284,6 +291,10 @@ func TestBuiltin_errors(t *testing.T) { {`flatten([1, 2], [3, 4])`, "invalid number of arguments (expected 1, got 2)"}, {`flatten(1)`, "cannot flatten int"}, {`fromJSON("5e2482")`, "cannot unmarshal number"}, + {`merge()`, "invalid number of arguments (expected at least 2, got 0)"}, + {`merge({"a": 1})`, "invalid number of arguments (expected at least 2, got 1)"}, + {`merge(1, {"a": 1})`, "cannot merge int"}, + {`merge({"a": 1}, 2)`, "cannot merge int"}, } for _, test := range errorTests { t.Run(test.input, func(t *testing.T) { diff --git a/docs/language-definition.md b/docs/language-definition.md index 69efbdfa9..ce5b4f355 100644 --- a/docs/language-definition.md +++ b/docs/language-definition.md @@ -880,6 +880,14 @@ Returns an array containing the values of the map. values({"name": "John", "age": 30}) == ["John", 30] ``` +### merge(map1, map2[, ...]) {#merge} + +Merges two or more maps into a new map. Keys of later maps override earlier ones. + +```expr +merge({"a": 1, "b": 2}, {"b": 3, "c": 4})["b"] == 3 +``` + ## Type Conversion Functions ### type(v) {#type} diff --git a/test/fuzz/fuzz_test.go b/test/fuzz/fuzz_test.go index e12c4d8ec..cb6d05b73 100644 --- a/test/fuzz/fuzz_test.go +++ b/test/fuzz/fuzz_test.go @@ -66,6 +66,7 @@ func FuzzExpr(f *testing.F) { regexp.MustCompile(`invalid order .*, expected asc or desc`), regexp.MustCompile(`unknown order, use asc or desc`), regexp.MustCompile(`cannot use .* as a key for groupBy: type is not comparable`), + regexp.MustCompile(`cannot merge .*`), } env := NewEnv() diff --git a/test/issues/895/issue_test.go b/test/issues/895/issue_test.go new file mode 100644 index 000000000..bba76ceac --- /dev/null +++ b/test/issues/895/issue_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "math" + "testing" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/internal/testify/require" +) + +func TestIssue895(t *testing.T) { + env := map[string]any{ + "a": map[string]any{"a": 1, "b": 2}, + "b": map[string]any{"b": 3, "c": 4}, + } + + program, err := expr.Compile(`merge(a, b)`, expr.Env(env)) + require.NoError(t, err) + + output, err := expr.Run(program, env) + require.NoError(t, err) + require.Equal(t, map[any]any{"a": 1, "b": 3, "c": 4}, output) +} + +func TestIssue895_does_not_modify_input(t *testing.T) { + a := map[string]any{"a": 1} + b := map[string]any{"b": 2} + env := map[string]any{ + "a": a, + "b": b, + } + + program, err := expr.Compile(`merge(a, b)`, expr.Env(env)) + require.NoError(t, err) + + _, err = expr.Run(program, env) + require.NoError(t, err) + + // Original maps must be unmodified. + require.Equal(t, map[string]any{"a": 1}, a) + require.Equal(t, map[string]any{"b": 2}, b) +} + +func TestIssue895_preserves_key_types(t *testing.T) { + env := map[string]any{ + "a": map[int]string{1: "int"}, + "b": map[string]string{"1": "string"}, + } + + program, err := expr.Compile(`merge(a, b)`, expr.Env(env)) + require.NoError(t, err) + + output, err := expr.Run(program, env) + require.NoError(t, err) + require.Equal(t, map[any]any{1: "int", "1": "string"}, output) +} + +func TestIssue895_keeps_non_reflexive_keys(t *testing.T) { + env := map[string]any{ + "a": map[float64]string{math.NaN(): "nan", 1: "one"}, + "b": map[float64]string{2: "two"}, + } + + program, err := expr.Compile(`merge(a, b)`, expr.Env(env)) + require.NoError(t, err) + + output, err := expr.Run(program, env) + require.NoError(t, err) + + merged := output.(map[any]any) + require.Len(t, merged, 3) + require.Equal(t, "one", merged[1.0]) + require.Equal(t, "two", merged[2.0]) +}