From 7fe024f33c75947b8f11e351b8ce887fa16d2fbd Mon Sep 17 00:00:00 2001 From: Dmitri Plotnikov Date: Tue, 20 Oct 2026 16:55:56 -0700 Subject: [PATCH] Add `CelFunction` builder and `CelBuilder.addFunctions` for unified function declaration and binding. - Introduce `CelFunction` and `CelFunction.Builder` in `dev.cel.bundle` with `addMemberOverload` and `addGlobalOverload` (unary, binary, and N-ary) that automatically generate overload IDs and group runtime bindings via `CelFunctionBinding.fromOverloads`. - Add `addFunctions(CelFunction...)` and `addFunctions(Iterable)` to `CelBuilder` and `CelImpl.Builder` to register both the compile-time declaration and runtime bindings in a single call. PiperOrigin-RevId: 1003500502 --- .../src/main/java/dev/cel/bundle/BUILD.bazel | 5 + .../main/java/dev/cel/bundle/CelBuilder.java | 14 + .../main/java/dev/cel/bundle/CelFunction.java | 242 +++++++++++++++++ .../src/main/java/dev/cel/bundle/CelImpl.java | 17 ++ .../java/dev/cel/bundle/CelFunctionTest.java | 243 ++++++++++++++++++ 5 files changed, 521 insertions(+) create mode 100644 bundle/src/main/java/dev/cel/bundle/CelFunction.java create mode 100644 bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java diff --git a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel index 40ce8df32..ce120220e 100644 --- a/bundle/src/main/java/dev/cel/bundle/BUILD.bazel +++ b/bundle/src/main/java/dev/cel/bundle/BUILD.bazel @@ -11,6 +11,7 @@ package( CEL_SOURCES = [ "Cel.java", "CelBuilder.java", + "CelFunction.java", ] java_library( @@ -19,9 +20,12 @@ java_library( tags = [ ], deps = [ + "//:auto_value", "//checker:checker_legacy_environment", "//checker:proto_type_mask", "//checker:standard_decl", + "//common:cel_function_decl", + "//common:cel_overload_decl", "//common:compiler_common", "//common:container", "//common:options", @@ -35,6 +39,7 @@ java_library( "@cel_spec//proto/cel/expr:checked_java_proto", "@maven//:com_google_code_findbugs_annotations", "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", ], ) diff --git a/bundle/src/main/java/dev/cel/bundle/CelBuilder.java b/bundle/src/main/java/dev/cel/bundle/CelBuilder.java index 53eb0126b..b3dcc270d 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelBuilder.java +++ b/bundle/src/main/java/dev/cel/bundle/CelBuilder.java @@ -117,6 +117,20 @@ public interface CelBuilder { @CanIgnoreReturnValue CelBuilder addFunctionDeclarations(Iterable celFunctionDecls); + /** + * Add one or more {@link CelFunction} definitions (combining compile-time declarations and + * runtime bindings) to the CEL environment. + */ + @CanIgnoreReturnValue + CelBuilder addFunctions(CelFunction... functions); + + /** + * Add a collection of {@link CelFunction} definitions (combining compile-time declarations and + * runtime bindings) to the CEL environment. + */ + @CanIgnoreReturnValue + CelBuilder addFunctions(Iterable functions); + /** Add variable declaration {@code CelVarDecl} to the CEL environment. */ @CanIgnoreReturnValue CelBuilder addVarDeclarations(CelVarDecl... celVarDecls); diff --git a/bundle/src/main/java/dev/cel/bundle/CelFunction.java b/bundle/src/main/java/dev/cel/bundle/CelFunction.java new file mode 100644 index 000000000..f2637b54e --- /dev/null +++ b/bundle/src/main/java/dev/cel/bundle/CelFunction.java @@ -0,0 +1,242 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dev.cel.bundle; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.errorprone.annotations.CheckReturnValue; +import com.google.errorprone.annotations.Immutable; +import dev.cel.common.CelFunctionDecl; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.types.CelType; +import dev.cel.runtime.CelFunctionBinding; +import dev.cel.runtime.CelFunctionOverload; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +/** + * Bundles a {@link CelFunctionDecl} and its corresponding runtime {@link CelFunctionBinding}s into + * a single unit that can be registered on {@link CelBuilder#addFunctions}. + * + *

Overload IDs are automatically generated from the function name and parameter types. + */ +@AutoValue +@Immutable +public abstract class CelFunction { + + CelFunction() {} + + /** The compile-time declaration for this function. */ + public abstract CelFunctionDecl functionDecl(); + + /** The runtime bindings for this function's overloads. */ + public abstract ImmutableSet functionBindings(); + + /** + * Creates a new builder to construct a {@link CelFunction} with the given {@code functionName}. + */ + public static Builder newBuilder(String functionName) { + return new Builder(functionName); + } + + /** Builder for configuring a {@link CelFunction}. */ + public static final class Builder { + private final String functionName; + private final List overloadDecls = new ArrayList<>(); + // List of functions that take an overload ID and create a CelFunctionBinding. Since the + // overload ID is not always known until build time, this list is applied to the overload IDs + // in `build()` + private final List> bindings = new ArrayList<>(); + + private Builder(String functionName) { + checkArgument(!Strings.isNullOrEmpty(functionName), "Function name cannot be null or empty"); + this.functionName = functionName; + } + + /** + * Adds a unary member overload (e.g. {@code x.f()}) with an automatically generated overload ID + * (e.g. {@code "targetType.f()"}). + */ + @CanIgnoreReturnValue + public Builder addMemberOverload( + CelType resultType, + CelType targetType, + Class targetClass, + CelFunctionOverload.Unary impl) { + checkNotNull(resultType); + checkNotNull(targetType); + checkNotNull(targetClass); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newMemberOverload(resultType, targetType)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, targetClass, impl)); + return this; + } + + /** + * Adds a binary member overload (e.g. {@code x.f(y)}) with an automatically generated overload + * ID (e.g. {@code "targetType.f(argType)"}). + */ + @CanIgnoreReturnValue + public Builder addMemberOverload( + CelType resultType, + CelType targetType, + CelType argType, + Class targetClass, + Class argClass, + CelFunctionOverload.Binary impl) { + checkNotNull(resultType); + checkNotNull(targetType); + checkNotNull(argType); + checkNotNull(targetClass); + checkNotNull(argClass); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newMemberOverload(resultType, targetType, argType)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, targetClass, argClass, impl)); + return this; + } + + /** + * Adds an N-ary member overload (e.g. {@code x.f(y, z, ...)}) with an automatically generated + * overload ID (e.g. {@code "targetType.f(arg1Type,arg2Type,...)"}), where the first element of + * {@code argTypes} and {@code argClasses} is the receiver {@code x}. + */ + @CanIgnoreReturnValue + public Builder addMemberOverload( + CelType resultType, + List argTypes, + List> argClasses, + CelFunctionOverload impl) { + return addNaryOverload( + resultType, argTypes, argClasses, impl, /* isInstanceFunction= */ true); + } + + /** + * Adds a unary global overload (e.g. {@code f(x)}) with an automatically generated overload ID + * (e.g. {@code "f(argType)"}). + */ + @CanIgnoreReturnValue + public Builder addGlobalOverload( + CelType resultType, CelType argType, Class argClass, CelFunctionOverload.Unary impl) { + checkNotNull(resultType); + checkNotNull(argType); + checkNotNull(argClass); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newGlobalOverload(resultType, argType)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, argClass, impl)); + return this; + } + + /** + * Adds a binary global overload (e.g. {@code f(x, y)}) with an automatically generated overload + * ID (e.g. {@code "f(arg1Type,arg2Type)"}). + */ + @CanIgnoreReturnValue + public Builder addGlobalOverload( + CelType resultType, + CelType arg1Type, + CelType arg2Type, + Class arg1Class, + Class arg2Class, + CelFunctionOverload.Binary impl) { + checkNotNull(resultType); + checkNotNull(arg1Type); + checkNotNull(arg2Type); + checkNotNull(arg1Class); + checkNotNull(arg2Class); + checkNotNull(impl); + overloadDecls.add(CelOverloadDecl.newGlobalOverload(resultType, arg1Type, arg2Type)); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, arg1Class, arg2Class, impl)); + return this; + } + + /** + * Adds an N-ary global overload (including zero-argument {@code f()}) with an automatically + * generated overload ID (e.g. {@code "f(arg1Type,arg2Type,...)"}). + */ + @CanIgnoreReturnValue + public Builder addGlobalOverload( + CelType resultType, + List argTypes, + List> argClasses, + CelFunctionOverload impl) { + return addNaryOverload( + resultType, argTypes, argClasses, impl, /* isInstanceFunction= */ false); + } + + @CanIgnoreReturnValue + private Builder addNaryOverload( + CelType resultType, + List argTypes, + List> argClasses, + CelFunctionOverload impl, + boolean isInstanceFunction) { + checkNotNull(resultType); + checkNotNull(argTypes); + checkNotNull(argClasses); + checkNotNull(impl); + checkArgument(!isInstanceFunction || !argTypes.isEmpty(), "Member function with no receiver"); + checkArgument( + argTypes.size() == argClasses.size(), + "Number of CelTypes (%s) must match number of Java classes (%s)", + argTypes.size(), + argClasses.size()); + overloadDecls.add( + isInstanceFunction + ? CelOverloadDecl.newMemberOverload(resultType, ImmutableList.copyOf(argTypes)) + : CelOverloadDecl.newGlobalOverload(resultType, ImmutableList.copyOf(argTypes))); + bindings.add(overloadId -> CelFunctionBinding.from(overloadId, argClasses, impl)); + return this; + } + + /** + * Builds a new {@link CelFunction} instance. + * + * @throws IllegalArgumentException if no overloads were added or if multiple overloads have the + * same canonical signature + */ + @CheckReturnValue + public CelFunction build() { + checkArgument( + !overloadDecls.isEmpty(), "Function '%s' must have at least one overload", functionName); + CelFunctionDecl functionDecl = + CelFunctionDecl.newFunctionDeclaration(functionName, overloadDecls); + ImmutableList populatedOverloads = functionDecl.overloads().asList(); + Set seen = new HashSet<>(); + ImmutableList.Builder bindingsBuilder = + ImmutableList.builderWithExpectedSize(populatedOverloads.size()); + for (int i = 0; i < overloadDecls.size(); i++) { + CelOverloadDecl decl = overloadDecls.get(i); + if (!seen.add(decl)) { + int firstIndex = overloadDecls.indexOf(decl); + throw new IllegalArgumentException( + "Duplicate overload signature: " + populatedOverloads.get(firstIndex).overloadId()); + } + bindingsBuilder.add(this.bindings.get(i).apply(populatedOverloads.get(i).overloadId())); + } + ImmutableSet bindings = + CelFunctionBinding.fromOverloads(functionName, bindingsBuilder.build()); + return new AutoValue_CelFunction(functionDecl, bindings); + } + } +} diff --git a/bundle/src/main/java/dev/cel/bundle/CelImpl.java b/bundle/src/main/java/dev/cel/bundle/CelImpl.java index b8c7c36e9..eed899562 100644 --- a/bundle/src/main/java/dev/cel/bundle/CelImpl.java +++ b/bundle/src/main/java/dev/cel/bundle/CelImpl.java @@ -245,6 +245,23 @@ public CelBuilder addFunctionDeclarations(Iterable celFunctionD return this; } + @Override + public CelBuilder addFunctions(CelFunction... functions) { + checkNotNull(functions); + return addFunctions(Arrays.asList(functions)); + } + + @Override + public CelBuilder addFunctions(Iterable functions) { + checkNotNull(functions); + for (CelFunction function : functions) { + checkNotNull(function); + compilerBuilder.addFunctionDeclarations(function.functionDecl()); + runtimeBuilder.addFunctionBindings(function.functionBindings()); + } + return this; + } + @Override public CelBuilder addVarDeclarations(CelVarDecl... celVarDecls) { compilerBuilder.addVarDeclarations(celVarDecls); diff --git a/bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java b/bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java new file mode 100644 index 000000000..43ad20af4 --- /dev/null +++ b/bundle/src/test/java/dev/cel/bundle/CelFunctionTest.java @@ -0,0 +1,243 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dev.cel.bundle; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.types.CelType; +import dev.cel.common.types.OpaqueType; +import dev.cel.common.types.SimpleType; +import dev.cel.testing.CelRuntimeFlavor; +import java.math.BigDecimal; +import java.math.RoundingMode; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class CelFunctionTest { + + private static final CelType DECIMAL_TYPE = OpaqueType.create("decimal"); + + @Test + public void memberOverloads_unary_evaluatesAndDispatchesDynamically( + @TestParameter CelRuntimeFlavor runtimeFlavor) throws Exception { + CelFunction floor = + CelFunction.newBuilder("floor") + .addMemberOverload( + SimpleType.INT, SimpleType.DOUBLE, Double.class, d -> (long) Math.floor(d)) + .addMemberOverload( + SimpleType.INT, + DECIMAL_TYPE, + BigDecimal.class, + bd -> bd.setScale(0, RoundingMode.FLOOR).longValue()) + .build(); + + assertThat(floor.functionDecl().name()).isEqualTo("floor"); + assertThat(floor.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("double.floor()", "decimal.floor()") + .inOrder(); + + Cel cel = + runtimeFlavor + .builder() + .addVar("d", SimpleType.DOUBLE) + .addVar("bd", DECIMAL_TYPE) + .addVar("x", SimpleType.DYN) + .addFunctions(floor) + .build(); + + CelAbstractSyntaxTree staticAst = cel.compile("d.floor() + bd.floor()").getAst(); + Object staticResult = + cel.createProgram(staticAst).eval(ImmutableMap.of("d", 3.9, "bd", new BigDecimal("7.8"))); + assertThat(staticResult).isEqualTo(10L); + + // Dynamic dispatch via dyn variable + CelAbstractSyntaxTree dynAst = cel.compile("x.floor()").getAst(); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("x", 5.7))).isEqualTo(5L); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("x", new BigDecimal("9.9")))) + .isEqualTo(9L); + } + + @Test + public void memberOverloads_binaryAndNary_evaluates(@TestParameter CelRuntimeFlavor runtimeFlavor) + throws Exception { + CelFunction repeat = + CelFunction.newBuilder("repeat") + .addMemberOverload( + SimpleType.STRING, + SimpleType.STRING, + SimpleType.INT, + String.class, + Long.class, + (s, count) -> { + StringBuilder sb = new StringBuilder(); + for (long i = 0; i < count; i++) { + sb.append(s); + } + return sb.toString(); + }) + .build(); + + CelFunction slice = + CelFunction.newBuilder("slice") + .addMemberOverload( + SimpleType.STRING, + ImmutableList.of(SimpleType.STRING, SimpleType.INT, SimpleType.INT), + ImmutableList.of(String.class, Long.class, Long.class), + args -> { + String s = (String) args[0]; + int start = ((Long) args[1]).intValue(); + int end = ((Long) args[2]).intValue(); + return s.substring(start, end); + }) + .build(); + + assertThat(repeat.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("string.repeat(int)"); + assertThat(slice.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("string.slice(int,int)"); + + Cel cel = runtimeFlavor.builder().addFunctions(ImmutableList.of(repeat, slice)).build(); + + CelAbstractSyntaxTree ast = cel.compile("'abcdef'.slice(1, 4).repeat(2)").getAst(); + assertThat(cel.createProgram(ast).eval()).isEqualTo("bcdbcd"); + } + + @Test + public void globalOverloads_unaryBinaryAndNary_evaluates( + @TestParameter CelRuntimeFlavor runtimeFlavor) throws Exception { + CelFunction doubleIt = + CelFunction.newBuilder("doubleIt") + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x * 2) + .addGlobalOverload(SimpleType.STRING, SimpleType.STRING, String.class, s -> s + s) + .build(); + + CelFunction joinWith = + CelFunction.newBuilder("joinWith") + .addGlobalOverload( + SimpleType.STRING, + SimpleType.STRING, + SimpleType.STRING, + String.class, + String.class, + (a, b) -> a + ":" + b) + .build(); + + CelFunction clamp = + CelFunction.newBuilder("clamp") + .addGlobalOverload(SimpleType.INT, ImmutableList.of(), ImmutableList.of(), args -> 0L) + .addGlobalOverload( + SimpleType.INT, + ImmutableList.of(SimpleType.INT, SimpleType.INT, SimpleType.INT), + ImmutableList.of(Long.class, Long.class, Long.class), + args -> { + long val = (Long) args[0]; + long min = (Long) args[1]; + long max = (Long) args[2]; + return Math.max(min, Math.min(max, val)); + }) + .build(); + + assertThat(doubleIt.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("doubleIt(int)", "doubleIt(string)") + .inOrder(); + assertThat(joinWith.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("joinWith(string,string)"); + assertThat(clamp.functionDecl().overloads().stream().map(CelOverloadDecl::overloadId)) + .containsExactly("clamp()", "clamp(int,int,int)") + .inOrder(); + + Cel cel = + runtimeFlavor + .builder() + .addVar("dynVal", SimpleType.DYN) + .addFunctions(doubleIt, joinWith, clamp) + .build(); + + CelAbstractSyntaxTree ast = + cel.compile("joinWith(doubleIt('a'), string(doubleIt(clamp(25, clamp(), 10))))").getAst(); + assertThat(cel.createProgram(ast).eval()).isEqualTo("aa:20"); + + // Dynamic dispatch on global overloaded function + CelAbstractSyntaxTree dynAst = cel.compile("doubleIt(dynVal)").getAst(); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("dynVal", 21L))).isEqualTo(42L); + assertThat(cel.createProgram(dynAst).eval(ImmutableMap.of("dynVal", "hi"))).isEqualTo("hihi"); + } + + @Test + public void builder_invalidInputs_throws() { + IllegalArgumentException emptyName = + assertThrows(IllegalArgumentException.class, () -> CelFunction.newBuilder("")); + assertThat(emptyName).hasMessageThat().isEqualTo("Function name cannot be null or empty"); + + CelFunction.Builder emptyBuilder = CelFunction.newBuilder("empty"); + IllegalArgumentException noOverloads = + assertThrows(IllegalArgumentException.class, emptyBuilder::build); + assertThat(noOverloads) + .hasMessageThat() + .isEqualTo("Function 'empty' must have at least one overload"); + + CelFunction.Builder duplicateOverloadBuilder = + CelFunction.newBuilder("dup") + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x) + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x + 1); + IllegalArgumentException duplicateOverload = + assertThrows(IllegalArgumentException.class, duplicateOverloadBuilder::build); + assertThat(duplicateOverload) + .hasMessageThat() + .isEqualTo("Duplicate overload signature: dup(int)"); + + CelFunction.Builder duplicateOverloadDifferentReturnBuilder = + CelFunction.newBuilder("dup") + .addGlobalOverload(SimpleType.INT, SimpleType.INT, Long.class, x -> x) + .addGlobalOverload(SimpleType.STRING, SimpleType.INT, Long.class, x -> "a"); + IllegalArgumentException duplicateOverloadDifferentReturn = + assertThrows( + IllegalArgumentException.class, duplicateOverloadDifferentReturnBuilder::build); + assertThat(duplicateOverloadDifferentReturn) + .hasMessageThat() + .isEqualTo("Duplicate overload signature: dup(int)"); + + CelFunction.Builder memberNoArgsBuilder = CelFunction.newBuilder("badMember"); + IllegalArgumentException memberNoArgs = + assertThrows( + IllegalArgumentException.class, + () -> + memberNoArgsBuilder.addMemberOverload( + SimpleType.INT, ImmutableList.of(), ImmutableList.of(), args -> 0L)); + assertThat(memberNoArgs).hasMessageThat().isEqualTo("Member function with no receiver"); + + CelFunction.Builder mismatchedArityBuilder = CelFunction.newBuilder("mismatch"); + IllegalArgumentException mismatchedArity = + assertThrows( + IllegalArgumentException.class, + () -> + mismatchedArityBuilder.addGlobalOverload( + SimpleType.INT, + ImmutableList.of(SimpleType.INT, SimpleType.INT), + ImmutableList.of(Long.class), + args -> 0L)); + assertThat(mismatchedArity) + .hasMessageThat() + .isEqualTo("Number of CelTypes (2) must match number of Java classes (1)"); + } +}