Skip to content

Add a maps extension with putAll - #1409

Open
lopster568 wants to merge 1 commit into
cel-expr:masterfrom
lopster568:maps-merge-extension
Open

lopster568 wants to merge 1 commit into
cel-expr:masterfrom
lopster568:maps-merge-extension

Conversation

@lopster568

@lopster568 lopster568 commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Rebased on master since the August approval. The cost code is ported to OverloadModel (see Implementation notes), and a repl test that was failing CI is fixed (see Testing). The API and semantics are unchanged.

Description

CEL has no way to combine two maps. The + operator concatenates strings, bytes, and lists, but has no map overload, and there is no maps extension.

This adds one, with a single member function:

<map(K, V)>.putAll(<map(K, V)>) -> map(K, V)

It returns a new map holding the entries of both maps, and the argument's values win on conflicting keys. Neither input is modified. The copy is shallow, so a value that is itself a map is replaced rather than merged recursively.

{}.putAll({})                              // {}
{'a': 1}.putAll({'b': 2})                  // {'a': 1, 'b': 2}
{'a': 1}.putAll({'a': 2})                  // {'a': 2}
{'a': {'x': 1}}.putAll({'a': {'y': 2}})    // {'a': {'y': 2}}

Scope

This is deliberately only the replace step from #1240. @TristonianJones suggested starting there:

I think replace semantics is where I'd start -- last write wins, followed by set-if-absent as the deep merge is the trickiest case

So set-if-absent and recursive merge are left for follow-ups.

Implementation notes

  • Cost is described by an OverloadModel registered with cel.CostModel, following the ext/lists migration in Migrate ext/lists to OverloadModel and add strategy soundness tests #1566. The call charges for visiting every entry of both maps plus a map allocation. The result size ranges from the larger input's entry count (every key collides) to the sum of both (no key collides), with key and value sizes taken from either input.
  • At cost model version 0 the estimates and tracked costs are unchanged from the earlier revision of this PR.
  • Entries are read with Find rather than Fold, for the reason given in the review thread: Fold hands back native Go values for reflect and string backed maps, which would then have to be adapted again.
  • Registered in ext/extension_option_factory.go (factory and alias) and in repl/evaluator.go. ext/README.md gains a Maps section.

Testing

ext/maps_test.go covers empty inputs, disjoint keys, conflicting keys, nested maps being replaced rather than merged, non-string keys, a variable receiver, compile-time rejection of non-map arguments, non-map arguments at runtime, and cost estimates and tracked costs at the latest model version and at version 0. The putAll cases are also added to TestCostSoundnessBySizingStrategy.

The earlier cel-go-builder run failed in its bazel-test step. Running cd repl && go test ./... reproduced a failure in TestProcessOptionError, whose expected list of extension options did not include maps. repl is a separate module, so my go test ./... from the root never covered it. That is fixed.

go build ./... and go test ./... pass in every module except conformance, where the policy tests need Bazel runfiles and fail the same way on master. go vet ./... is clean for this change; its only findings are unreachable code warnings in the generated ANTLR parsers, the same count as on master. I did not run the Bazel build locally.

@lopster568
lopster568 force-pushed the maps-merge-extension branch from 51aea9b to edbceda Compare August 8, 2026 03:44
Comment thread ext/maps.go Resolved
Comment thread ext/maps.go Resolved
Comment thread ext/maps.go Resolved
Comment thread ext/maps.go Resolved
@TristonianJones

Copy link
Copy Markdown
Collaborator

/gcbrun

1 similar comment
@TristonianJones

Copy link
Copy Markdown
Collaborator

/gcbrun

@TristonianJones

Copy link
Copy Markdown
Collaborator

/gcbrun

CEL has no operator for combining two maps: + concatenates strings,
bytes, and lists but is not defined for maps, and there is no maps
extension.

Add a.putAll(b), a member function returning a new map with the entries
of both maps and the argument's values winning on conflicting keys.
Neither input is modified. The copy is shallow, so a value that is
itself a map is replaced rather than merged.

This is the replace step of the merge semantics discussed in cel-expr#1240;
set-if-absent and recursive merge are left for follow-ups.

The cost is described by an OverloadModel: the call charges for visiting
every entry of both maps plus a map allocation, and the result holds
between the larger input's entry count and the sum of both, with keys
and values drawn from either input. The putAll cases are added to the
sizing strategy soundness test.

Registers the library in the extension option factory and the repl,
and documents it in ext/README.md.
@lopster568
lopster568 force-pushed the maps-merge-extension branch from d081260 to 117cd07 Compare November 3, 2026 07:46
@lopster568 lopster568 changed the title Add a maps extension with maps.merge Add a maps extension with putAll Nov 3, 2026
@lopster568

Copy link
Copy Markdown
Contributor Author

Rebased onto master and moved the cost estimate to OverloadModel, following #1566. I couldn't read the cel-go-builder log, but TestProcessOptionError in repl failed locally because its expected list didn't include maps; that's fixed too. @TristonianJones could you /gcbrun again when you have a moment?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants