From 8deca2a23613388d10f2e9cd829f2d0568a4043a Mon Sep 17 00:00:00 2001 From: Sean Huh Date: Wed, 2 Sep 2026 12:42:32 -0700 Subject: [PATCH] Compile policy tooling with field selection optimization applied PiperOrigin-RevId: 975271045 --- .../java/dev/cel/common/values/BUILD.bazel | 6 + .../values/ProtoLiteCelValueConverter.java | 38 +- .../common/values/ProtoMessageLiteValue.java | 19 +- .../values/RawProtoMessageLiteValue.java | 242 +++++ .../java/dev/cel/common/values/BUILD.bazel | 2 + .../values/ProtoMessageLiteValueTest.java | 39 +- .../values/RawProtoMessageLiteValueTest.java | 457 ++++++++++ .../java/dev/cel/compiler/tools/BUILD.bazel | 23 +- .../cel/compiler/tools/CelCompilerTool.java | 54 ++ .../java/dev/cel/compiler/tools/BUILD.bazel | 5 + .../compiler/tools/CelCompilerToolTest.java | 169 +++- compiler/tools/compile_cel.bzl | 20 +- optimizer/optimizers/BUILD.bazel | 5 + .../dev/cel/optimizer/optimizers/BUILD.bazel | 34 + .../optimizer/optimizers/SelectOptimizer.java | 477 ++++++++++ .../dev/cel/optimizer/optimizers/BUILD.bazel | 4 + .../optimizers/SelectOptimizerTest.java | 765 ++++++++++++++++ .../java/dev/cel/policy/tools/BUILD.bazel | 49 + .../policy/tools/CelPolicyCompilerTool.java | 299 +++++++ .../java/dev/cel/policy/tools/BUILD.bazel | 60 ++ .../tools/CelPolicyCompilerToolTest.java | 839 ++++++++++++++++++ .../dev/cel/policy/tools/test_policy.yaml | 6 + policy/tools/BUILD.bazel | 11 + policy/tools/compile_cel_policy.bzl | 107 +++ .../dev/cel/protobuf/CelLiteDescriptor.java | 82 +- .../test/java/dev/cel/protobuf/BUILD.bazel | 1 + .../cel/protobuf/CelLiteDescriptorTest.java | 98 ++ runtime/planner/BUILD.bazel | 10 + .../src/main/java/dev/cel/runtime/BUILD.bazel | 9 + .../dev/cel/runtime/LiteAttributeStep.java | 391 ++++++++ .../java/dev/cel/runtime/LiteRuntimeImpl.java | 36 + .../java/dev/cel/runtime/planner/BUILD.bazel | 6 + .../src/test/java/dev/cel/runtime/BUILD.bazel | 12 +- .../runtime/CelLiteRuntimeAndroidTest.java | 146 ++- .../CelLiteRuntimeVersionSkewTest.java | 464 ++++++++++ .../cel/runtime/LiteAttributeStepTest.java | 703 +++++++++++++++ .../java/dev/cel/testing/compiled/BUILD.bazel | 55 ++ 37 files changed, 5692 insertions(+), 51 deletions(-) create mode 100644 common/src/main/java/dev/cel/common/values/RawProtoMessageLiteValue.java create mode 100644 common/src/test/java/dev/cel/common/values/RawProtoMessageLiteValueTest.java create mode 100644 optimizer/src/main/java/dev/cel/optimizer/optimizers/SelectOptimizer.java create mode 100644 optimizer/src/test/java/dev/cel/optimizer/optimizers/SelectOptimizerTest.java create mode 100644 policy/src/main/java/dev/cel/policy/tools/BUILD.bazel create mode 100644 policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java create mode 100644 policy/src/test/java/dev/cel/policy/tools/BUILD.bazel create mode 100644 policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java create mode 100644 policy/src/test/java/dev/cel/policy/tools/test_policy.yaml create mode 100644 policy/tools/BUILD.bazel create mode 100644 policy/tools/compile_cel_policy.bzl create mode 100644 runtime/src/main/java/dev/cel/runtime/LiteAttributeStep.java create mode 100644 runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeVersionSkewTest.java create mode 100644 runtime/src/test/java/dev/cel/runtime/LiteAttributeStepTest.java diff --git a/common/src/main/java/dev/cel/common/values/BUILD.bazel b/common/src/main/java/dev/cel/common/values/BUILD.bazel index 433dcd477..c39eaaa73 100644 --- a/common/src/main/java/dev/cel/common/values/BUILD.bazel +++ b/common/src/main/java/dev/cel/common/values/BUILD.bazel @@ -317,6 +317,7 @@ java_library( srcs = [ "ProtoLiteCelValueConverter.java", "ProtoMessageLiteValue.java", + "RawProtoMessageLiteValue.java", ], tags = [ ], @@ -325,6 +326,7 @@ java_library( ":values", "//:auto_value", "//common/annotations", + "//common/exceptions:attribute_not_found", "//common/internal:cel_lite_descriptor_pool", "//common/internal:well_known_proto", "//common/types", @@ -333,6 +335,7 @@ java_library( "//protobuf:cel_lite_descriptor", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", + "@maven//:org_jspecify_jspecify", "@maven_android//:com_google_protobuf_protobuf_javalite", ], ) @@ -342,6 +345,7 @@ cel_android_library( srcs = [ "ProtoLiteCelValueConverter.java", "ProtoMessageLiteValue.java", + "RawProtoMessageLiteValue.java", ], tags = [ ], @@ -350,6 +354,7 @@ cel_android_library( ":values_android", "//:auto_value", "//common/annotations", + "//common/exceptions:attribute_not_found", "//common/internal:cel_lite_descriptor_pool_android", "//common/internal:well_known_proto_android", "//common/types:type_providers_android", @@ -358,6 +363,7 @@ cel_android_library( "//protobuf:cel_lite_descriptor", "@maven//:com_google_errorprone_error_prone_annotations", "@maven//:com_google_guava_guava", + "@maven//:org_jspecify_jspecify", "@maven_android//:com_google_guava_guava", "@maven_android//:com_google_protobuf_protobuf_javalite", ], diff --git a/common/src/main/java/dev/cel/common/values/ProtoLiteCelValueConverter.java b/common/src/main/java/dev/cel/common/values/ProtoLiteCelValueConverter.java index 64d6ec1d4..7cfcb93ec 100644 --- a/common/src/main/java/dev/cel/common/values/ProtoLiteCelValueConverter.java +++ b/common/src/main/java/dev/cel/common/values/ProtoLiteCelValueConverter.java @@ -20,6 +20,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Defaults; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Multimap; import com.google.common.collect.Multimaps; @@ -45,6 +46,7 @@ import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.TreeMap; /** @@ -160,6 +162,19 @@ Object getDefaultCelValue(String protoTypeName, String fieldName) { return toRuntimeValue(defaultValue); } + @Internal + public Optional findFieldDescriptor(String protoTypeName, String fieldName) { + return descriptorPool + .findDescriptor(protoTypeName) + .flatMap(desc -> desc.findByFieldName(fieldName)); + } + + @Internal + public Optional findDefaultCelValue(String protoTypeName, String fieldName) { + return findFieldDescriptor(protoTypeName, fieldName) + .map(fieldDescriptor -> toRuntimeValue(getDefaultValue(fieldDescriptor))); + } + @Override @SuppressWarnings("LiteProtoToString") // No alternative identifier to use. Debug only info is OK. public Object toRuntimeValue(Object value) { @@ -193,7 +208,10 @@ protected Object fromWellKnownProto(MessageLiteOrBuilder msg, WellKnownProto wel descriptorPool .findDescriptor(message) .orElseThrow( - () -> new NoSuchElementException("Could not find a descriptor for: " + message)); + () -> + new NoSuchElementException( + "Could not find a descriptor for message of type: " + + message.getClass().getName())); return ProtoMessageLiteValue.create(message, descriptor.getProtoTypeName(), this); } @@ -369,11 +387,14 @@ MessageFields readAllFields(byte[] bytes, String protoTypeName) throws IOExcepti ImmutableMap readAllFields(MessageLite msg, String protoTypeName) throws IOException { - return readAllFields(msg.toByteArray(), protoTypeName).values(); + return readMessageFields(msg, protoTypeName).values(); } - private static Object readUnknownField(int tagWireType, CodedInputStream inputStream) - throws IOException { + MessageFields readMessageFields(MessageLite msg, String protoTypeName) throws IOException { + return readAllFields(msg.toByteArray(), protoTypeName); + } + + static Object readUnknownField(int tagWireType, CodedInputStream inputStream) throws IOException { switch (tagWireType) { case WireFormat.WIRETYPE_VARINT: return inputStream.readInt64(); @@ -393,16 +414,19 @@ private static Object readUnknownField(int tagWireType, CodedInputStream inputSt } @AutoValue - @SuppressWarnings("AutoValueImmutableFields") // Unknowns are inaccessible to users. + @AutoValue.CopyAnnotations + @Immutable + @SuppressWarnings("Immutable") // Safe immutable fields abstract static class MessageFields { abstract ImmutableMap values(); - abstract Multimap unknowns(); + abstract ImmutableListMultimap unknowns(); static MessageFields create( ImmutableMap fieldValues, Multimap unknownFields) { - return new AutoValue_ProtoLiteCelValueConverter_MessageFields(fieldValues, unknownFields); + return new AutoValue_ProtoLiteCelValueConverter_MessageFields( + fieldValues, ImmutableListMultimap.copyOf(unknownFields)); } } diff --git a/common/src/main/java/dev/cel/common/values/ProtoMessageLiteValue.java b/common/src/main/java/dev/cel/common/values/ProtoMessageLiteValue.java index 2e4d980c7..d31f71b56 100644 --- a/common/src/main/java/dev/cel/common/values/ProtoMessageLiteValue.java +++ b/common/src/main/java/dev/cel/common/values/ProtoMessageLiteValue.java @@ -17,11 +17,14 @@ import com.google.auto.value.AutoValue; import com.google.auto.value.extension.memoized.Memoized; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; import com.google.errorprone.annotations.Immutable; import com.google.protobuf.MessageLite; +import dev.cel.common.annotations.Internal; import dev.cel.common.types.CelType; import dev.cel.common.types.StructTypeReference; +import dev.cel.common.values.ProtoLiteCelValueConverter.MessageFields; import java.io.IOException; import java.util.Optional; @@ -43,17 +46,27 @@ public abstract class ProtoMessageLiteValue extends StructValue fieldValues() { + MessageFields messageFields() { try { - return protoLiteCelValueConverter().readAllFields(value(), celType().name()); + return protoLiteCelValueConverter().readMessageFields(value(), celType().name()); } catch (IOException e) { throw new IllegalStateException("Unable to read message fields for " + celType().name(), e); } } + @Internal + public ImmutableMap fieldValues() { + return messageFields().values(); + } + + public ImmutableListMultimap unknownFields() { + return messageFields().unknowns(); + } + @Override public boolean isZeroValue() { return value().getDefaultInstanceForType().equals(value()); diff --git a/common/src/main/java/dev/cel/common/values/RawProtoMessageLiteValue.java b/common/src/main/java/dev/cel/common/values/RawProtoMessageLiteValue.java new file mode 100644 index 000000000..ee6414c3b --- /dev/null +++ b/common/src/main/java/dev/cel/common/values/RawProtoMessageLiteValue.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.common.values; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.auto.value.extension.memoized.Memoized; +import com.google.common.collect.ImmutableCollection; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.Iterables; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; +import com.google.common.primitives.UnsignedLong; +import com.google.errorprone.annotations.Immutable; +import com.google.protobuf.ByteString; +import com.google.protobuf.CodedInputStream; +import com.google.protobuf.WireFormat; +import dev.cel.common.annotations.Internal; +import dev.cel.common.exceptions.CelAttributeNotFoundException; +import dev.cel.common.types.CelType; +import dev.cel.common.types.StructTypeReference; +import dev.cel.protobuf.CelLiteDescriptor.FieldLiteDescriptor; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Optional; +import java.util.TreeMap; +import org.jspecify.annotations.Nullable; + +/** + * RawProtoMessageLiteValue represents a submessage whose concrete Java class is missing from the + * runtime environment (such as when client-server version skew introduces a new submessage). + * + *

Rather than generating or reflecting on a {@link com.google.protobuf.MessageLite} class, this + * value wraps the raw wire-format {@link ByteString} payload and exposes classless, reflection-free + * field traversal using {@link CodedInputStream}. + */ +@AutoValue +@AutoValue.CopyAnnotations +@Immutable +@SuppressWarnings("Immutable") // Immutable wire fields +@Internal +public abstract class RawProtoMessageLiteValue + extends StructValue { + + public abstract ByteString rawWireBytes(); + + @Override + public RawProtoMessageLiteValue value() { + return this; + } + + @Override + public abstract CelType celType(); + + @Memoized + public ImmutableListMultimap unknownFields() { + try { + CodedInputStream inputStream = rawWireBytes().newCodedInput(); + Multimap fields = Multimaps.newMultimap(new TreeMap<>(), ArrayList::new); + for (int tag = inputStream.readTag(); tag != 0; tag = inputStream.readTag()) { + int tagWireType = WireFormat.getTagWireType(tag); + int fieldNumber = WireFormat.getTagFieldNumber(tag); + fields.put( + fieldNumber, ProtoLiteCelValueConverter.readUnknownField(tagWireType, inputStream)); + } + return ImmutableListMultimap.copyOf(fields); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse raw proto message wire bytes", e); + } + } + + public boolean hasField(int fieldNumber) { + return unknownFields().containsKey(fieldNumber); + } + + @Override + public boolean isZeroValue() { + return rawWireBytes().isEmpty(); + } + + @Override + public Object select(String field) { + throw CelAttributeNotFoundException.forFieldResolution(field); + } + + @Override + public Optional find(String field) { + return Optional.empty(); + } + + public static @Nullable Object decodeWireEntries( + ImmutableCollection entries, int typeCode, String protoTypeName, boolean isRepeated) { + if (entries.isEmpty()) { + return null; + } + WireFormat.FieldType fieldType = + FieldLiteDescriptor.Type.forNumber(typeCode).toWireFormatFieldType(); + if (isRepeated) { + // Packed repeated scalar check: single ByteString containing packed varints/fixed numbers + if (entries.size() == 1 + && (entries.iterator().next() instanceof ByteString) + && fieldType.isPackable()) { + return decodePacked((ByteString) entries.iterator().next(), fieldType); + } + ImmutableList.Builder listBuilder = ImmutableList.builder(); + for (Object raw : entries) { + listBuilder.add(decodeWireValue(raw, fieldType, protoTypeName)); + } + return listBuilder.build(); + } + // Protobuf "last one wins" semantics for non-repeated fields + Object last = Iterables.getLast(entries, null); + return decodeWireValue(last, fieldType, protoTypeName); + } + + public static Object decodeWireValue(Object raw, int typeCode, String protoTypeName) { + return decodeWireValue( + raw, FieldLiteDescriptor.Type.forNumber(typeCode).toWireFormatFieldType(), protoTypeName); + } + + public static Object decodeWireValue( + Object raw, WireFormat.FieldType fieldType, String protoTypeName) { + switch (fieldType) { + case DOUBLE: + return Double.longBitsToDouble((Long) raw); + case FLOAT: + return (double) Float.intBitsToFloat((Integer) raw); + case INT64: + case INT32: + case SFIXED64: + case ENUM: + return raw; + case UINT64: + case FIXED64: + return UnsignedLong.fromLongBits((Long) raw); + case FIXED32: + return UnsignedLong.fromLongBits(Integer.toUnsignedLong((Integer) raw)); + case BOOL: + return ((Long) raw) != 0L; + case STRING: + return ((ByteString) raw).toStringUtf8(); + case GROUP: + case MESSAGE: + return RawProtoMessageLiteValue.create((ByteString) raw, protoTypeName); + case BYTES: + return CelByteString.of(((ByteString) raw).toByteArray()); + case UINT32: + return UnsignedLong.fromLongBits(((Long) raw) & 0xFFFFFFFFL); + case SFIXED32: + return ((Integer) raw).longValue(); + case SINT32: + return (long) CodedInputStream.decodeZigZag32((int) (long) (Long) raw); + case SINT64: + return CodedInputStream.decodeZigZag64((Long) raw); + } + throw new IllegalArgumentException("Unsupported proto field type: " + fieldType); + } + + private static ImmutableList decodePacked( + ByteString bytes, WireFormat.FieldType fieldType) { + try { + CodedInputStream in = bytes.newCodedInput(); + ImmutableList.Builder builder = ImmutableList.builder(); + while (!in.isAtEnd()) { + switch (fieldType) { + case DOUBLE: + builder.add(Double.longBitsToDouble(in.readFixed64())); + break; + case FLOAT: + builder.add((double) Float.intBitsToFloat(in.readFixed32())); + break; + case INT64: + builder.add(in.readInt64()); + break; + case UINT64: + builder.add(UnsignedLong.fromLongBits(in.readUInt64())); + break; + case INT32: + builder.add((long) in.readInt32()); + break; + case FIXED64: + builder.add(UnsignedLong.fromLongBits(in.readFixed64())); + break; + case FIXED32: + builder.add(UnsignedLong.fromLongBits(Integer.toUnsignedLong(in.readFixed32()))); + break; + case BOOL: + builder.add(in.readBool()); + break; + case UINT32: + builder.add(UnsignedLong.fromLongBits(Integer.toUnsignedLong(in.readUInt32()))); + break; + case ENUM: + builder.add((long) in.readEnum()); + break; + case SFIXED32: + builder.add((long) in.readSFixed32()); + break; + case SFIXED64: + builder.add(in.readSFixed64()); + break; + case SINT32: + builder.add((long) in.readSInt32()); + break; + case SINT64: + builder.add(in.readSInt64()); + break; + default: + throw new IllegalArgumentException("Unsupported packed proto field type: " + fieldType); + } + } + return builder.build(); + } catch (IOException e) { + throw new IllegalStateException("Failed to parse packed repeated field", e); + } + } + + public static RawProtoMessageLiteValue create(ByteString rawWireBytes) { + return create(rawWireBytes, ""); + } + + public static RawProtoMessageLiteValue create(ByteString rawWireBytes, String protoTypeName) { + checkNotNull(rawWireBytes); + checkNotNull(protoTypeName); + return new AutoValue_RawProtoMessageLiteValue( + rawWireBytes, StructTypeReference.create(protoTypeName)); + } +} diff --git a/common/src/test/java/dev/cel/common/values/BUILD.bazel b/common/src/test/java/dev/cel/common/values/BUILD.bazel index 76c761567..baa33ebc3 100644 --- a/common/src/test/java/dev/cel/common/values/BUILD.bazel +++ b/common/src/test/java/dev/cel/common/values/BUILD.bazel @@ -15,6 +15,7 @@ java_library( "//common:cel_ast", "//common:cel_descriptor_util", "//common:options", + "//common/exceptions:attribute_not_found", "//common/internal:cel_descriptor_pools", "//common/internal:cel_lite_descriptor_pool", "//common/internal:default_lite_descriptor_pool", @@ -32,6 +33,7 @@ java_library( "//common/values:proto_message_lite_value_provider", "//common/values:proto_message_value", "//common/values:proto_message_value_provider", + "//protobuf:cel_lite_descriptor", "//testing/protos:test_all_types_cel_java_proto3", "@cel_spec//proto/cel/expr/conformance/proto2:test_all_types_java_proto", "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto", diff --git a/common/src/test/java/dev/cel/common/values/ProtoMessageLiteValueTest.java b/common/src/test/java/dev/cel/common/values/ProtoMessageLiteValueTest.java index dbfb55cf9..88799878e 100644 --- a/common/src/test/java/dev/cel/common/values/ProtoMessageLiteValueTest.java +++ b/common/src/test/java/dev/cel/common/values/ProtoMessageLiteValueTest.java @@ -21,11 +21,17 @@ import com.google.common.collect.ImmutableSet; import com.google.common.primitives.UnsignedLong; import com.google.protobuf.Any; +import com.google.protobuf.BoolValue; import com.google.protobuf.ByteString; +import com.google.protobuf.BytesValue; +import com.google.protobuf.CodedOutputStream; +import com.google.protobuf.DoubleValue; import com.google.protobuf.DynamicMessage; +import com.google.protobuf.ExtensionRegistryLite; import com.google.protobuf.FloatValue; import com.google.protobuf.Int32Value; import com.google.protobuf.Int64Value; +import com.google.protobuf.StringValue; import com.google.protobuf.Timestamp; import com.google.protobuf.UInt32Value; import com.google.protobuf.UInt64Value; @@ -37,6 +43,7 @@ import dev.cel.expr.conformance.proto3.TestAllTypes.NestedEnum; import dev.cel.expr.conformance.proto3.TestAllTypes.NestedMessage; import dev.cel.expr.conformance.proto3.TestAllTypesCelDescriptor; +import java.io.ByteArrayOutputStream; import java.time.Duration; import java.time.Instant; import org.junit.Test; @@ -153,19 +160,17 @@ public void selectField_success(@TestParameter SelectFieldTestCase testCase) { .setSingleDouble(2.5d) .setSingleString("test") .setSingleBytes(ByteString.copyFrom(new byte[] {0x01})) - .setSingleAny( - Any.pack(DynamicMessage.newBuilder(com.google.protobuf.BoolValue.of(true)).build())) + .setSingleAny(Any.pack(DynamicMessage.newBuilder(BoolValue.of(true)).build())) .setSingleDuration(com.google.protobuf.Duration.newBuilder().setSeconds(100)) .setSingleTimestamp(Timestamp.newBuilder().setSeconds(100)) .setSingleInt32Wrapper(Int32Value.of(5)) .setSingleInt64Wrapper(Int64Value.of(10L)) .setSingleUint32Wrapper(UInt32Value.of(1)) .setSingleUint64Wrapper(UInt64Value.of(UnsignedLong.MAX_VALUE.longValue())) - .setSingleStringWrapper(com.google.protobuf.StringValue.of("hello")) + .setSingleStringWrapper(StringValue.of("hello")) .setSingleFloatWrapper(FloatValue.of(7.5f)) - .setSingleDoubleWrapper(com.google.protobuf.DoubleValue.of(8.5d)) - .setSingleBytesWrapper( - com.google.protobuf.BytesValue.of(ByteString.copyFrom(new byte[] {0x02}))) + .setSingleDoubleWrapper(DoubleValue.of(8.5d)) + .setSingleBytesWrapper(BytesValue.of(ByteString.copyFrom(new byte[] {0x02}))) .addRepeatedInt64(5L) .addRepeatedInt64(6L) .addRepeatedUint64(7L) @@ -253,4 +258,26 @@ public void selectField_defaultValue(@TestParameter DefaultValueTestCase testCas assertThat(selectedValue).isEqualTo(testCase.value); } + + @Test + public void unknownFields_retainsUnknownWireFields() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt64(999, 12345L); + cos.writeString(1000, "hello unknown"); + cos.flush(); + + TestAllTypes msgWithUnknown = + TestAllTypes.parseFrom(baos.toByteArray(), ExtensionRegistryLite.getEmptyRegistry()); + ProtoMessageLiteValue messageLiteValue = + ProtoMessageLiteValue.create( + msgWithUnknown, + "cel.expr.conformance.proto3.TestAllTypes", + PROTO_LITE_CEL_VALUE_CONVERTER); + + assertThat(messageLiteValue.unknownFields()).valuesForKey(999).containsExactly(12345L); + assertThat(messageLiteValue.unknownFields()) + .valuesForKey(1000) + .containsExactly(ByteString.copyFromUtf8("hello unknown")); + } } diff --git a/common/src/test/java/dev/cel/common/values/RawProtoMessageLiteValueTest.java b/common/src/test/java/dev/cel/common/values/RawProtoMessageLiteValueTest.java new file mode 100644 index 000000000..3ac531a83 --- /dev/null +++ b/common/src/test/java/dev/cel/common/values/RawProtoMessageLiteValueTest.java @@ -0,0 +1,457 @@ +// 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.common.values; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.UnsignedLong; +import com.google.protobuf.ByteString; +import com.google.protobuf.CodedOutputStream; +import com.google.protobuf.WireFormat; +import dev.cel.common.exceptions.CelAttributeNotFoundException; +import dev.cel.protobuf.CelLiteDescriptor.FieldLiteDescriptor; +import java.io.ByteArrayOutputStream; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class RawProtoMessageLiteValueTest { + + @Test + public void create_accessorsAndType() { + ByteString bytes = ByteString.copyFromUtf8("test"); + RawProtoMessageLiteValue value = RawProtoMessageLiteValue.create(bytes, "custom.Message"); + + assertThat(value.rawWireBytes()).isEqualTo(bytes); + assertThat(value.value()).isSameInstanceAs(value); + assertThat(value.celType().name()).isEqualTo("custom.Message"); + } + + @Test + public void create_singleArgDefaultsEmptyTypeName() { + ByteString bytes = ByteString.copyFromUtf8("test"); + RawProtoMessageLiteValue value = RawProtoMessageLiteValue.create(bytes); + + assertThat(value.rawWireBytes()).isEqualTo(bytes); + assertThat(value.celType().name()).isEmpty(); + } + + @Test + public void select_throwsCelAttributeNotFoundException() { + RawProtoMessageLiteValue value = + RawProtoMessageLiteValue.create(ByteString.EMPTY, "custom.Message"); + + assertThrows(CelAttributeNotFoundException.class, () -> value.select("field")); + } + + @Test + public void find_returnsEmptyOptional() { + RawProtoMessageLiteValue value = + RawProtoMessageLiteValue.create(ByteString.EMPTY, "custom.Message"); + + assertThat(value.find("field")).isEmpty(); + } + + @Test + public void unknownFields_parsesWireTags() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt64(1, 42L); + cos.writeFixed32(2, 100); + cos.writeFixed64(3, 200L); + cos.writeString(4, "hello"); + cos.flush(); + + RawProtoMessageLiteValue value = + RawProtoMessageLiteValue.create(ByteString.copyFrom(baos.toByteArray())); + + assertThat(value.unknownFields()).valuesForKey(1).containsExactly(42L); + assertThat(value.unknownFields()).valuesForKey(2).containsExactly(100); + assertThat(value.unknownFields()).valuesForKey(3).containsExactly(200L); + assertThat(value.unknownFields()) + .valuesForKey(4) + .containsExactly(ByteString.copyFromUtf8("hello")); + } + + @Test + public void decodeWireEntries_emptyEntries_returnsNull() { + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(), + FieldLiteDescriptor.Type.INT64.getNumber(), + "custom.Message", + /* isRepeated= */ false)) + .isNull(); + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(), + FieldLiteDescriptor.Type.MESSAGE.getNumber(), + "custom.Message", + /* isRepeated= */ false)) + .isNull(); + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(), + FieldLiteDescriptor.Type.INT64.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isNull(); + } + + @Test + public void decodeWireEntries_nonRepeated_lastOneWins() { + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(10L, 20L, 30L), + FieldLiteDescriptor.Type.INT64.getNumber(), + "custom.Message", + /* isRepeated= */ false); + + assertThat(decoded).isEqualTo(30L); + } + + @Test + public void decodeWireEntries_repeatedUnpacked() { + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(10L, 20L, 30L), + FieldLiteDescriptor.Type.INT64.getNumber(), + "custom.Message", + /* isRepeated= */ true); + + assertThat(decoded).isEqualTo(ImmutableList.of(10L, 20L, 30L)); + } + + @Test + public void decodeWireEntries_packedInt32() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt32NoTag(1); + cos.writeInt32NoTag(2); + cos.writeInt32NoTag(3); + cos.flush(); + + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray())), + FieldLiteDescriptor.Type.INT32.getNumber(), + "custom.Message", + /* isRepeated= */ true); + + assertThat(decoded).isEqualTo(ImmutableList.of(1L, 2L, 3L)); + } + + @Test + public void decodeWireEntries_packedInt64() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt64NoTag(100L); + cos.writeInt64NoTag(200L); + cos.flush(); + + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray())), + FieldLiteDescriptor.Type.INT64.getNumber(), + "custom.Message", + /* isRepeated= */ true); + + assertThat(decoded).isEqualTo(ImmutableList.of(100L, 200L)); + } + + @Test + public void decodeWireEntries_packedUint32() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeUInt32NoTag(50); + cos.flush(); + + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray())), + FieldLiteDescriptor.Type.UINT32.getNumber(), + "custom.Message", + /* isRepeated= */ true); + + assertThat(decoded).isEqualTo(ImmutableList.of(UnsignedLong.fromLongBits(50L))); + } + + @Test + public void decodeWireEntries_packedUint64() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeUInt64NoTag(999L); + cos.flush(); + + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray())), + FieldLiteDescriptor.Type.UINT64.getNumber(), + "custom.Message", + /* isRepeated= */ true); + + assertThat(decoded).isEqualTo(ImmutableList.of(UnsignedLong.fromLongBits(999L))); + } + + @Test + public void decodeWireEntries_packedSint32AndSint64() throws Exception { + ByteArrayOutputStream baos32 = new ByteArrayOutputStream(); + CodedOutputStream cos32 = CodedOutputStream.newInstance(baos32); + cos32.writeSInt32NoTag(-10); + cos32.writeSInt32NoTag(20); + cos32.flush(); + + Object decoded32 = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos32.toByteArray())), + FieldLiteDescriptor.Type.SINT32.getNumber(), + "custom.Message", + /* isRepeated= */ true); + + assertThat(decoded32).isEqualTo(ImmutableList.of(-10L, 20L)); + + ByteArrayOutputStream baos64 = new ByteArrayOutputStream(); + CodedOutputStream cos64 = CodedOutputStream.newInstance(baos64); + cos64.writeSInt64NoTag(-100L); + cos64.writeSInt64NoTag(200L); + cos64.flush(); + + Object decoded64 = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos64.toByteArray())), + FieldLiteDescriptor.Type.SINT64.getNumber(), + "custom.Message", + /* isRepeated= */ true); + + assertThat(decoded64).isEqualTo(ImmutableList.of(-100L, 200L)); + } + + @Test + public void decodeWireEntries_packedFixedAndSFixed() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeFixed32NoTag(10); + cos.writeFixed64NoTag(20L); + cos.writeSFixed32NoTag(-30); + cos.writeSFixed64NoTag(-40L); + cos.flush(); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray()).substring(0, 4)), + FieldLiteDescriptor.Type.FIXED32.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(UnsignedLong.fromLongBits(10L))); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray()).substring(4, 12)), + FieldLiteDescriptor.Type.FIXED64.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(UnsignedLong.fromLongBits(20L))); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray()).substring(12, 16)), + FieldLiteDescriptor.Type.SFIXED32.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(-30L)); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray()).substring(16, 24)), + FieldLiteDescriptor.Type.SFIXED64.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(-40L)); + } + + @Test + public void decodeWireEntries_packedBoolFloatDoubleEnum() throws Exception { + ByteArrayOutputStream baosBool = new ByteArrayOutputStream(); + CodedOutputStream cosBool = CodedOutputStream.newInstance(baosBool); + cosBool.writeBoolNoTag(true); + cosBool.writeBoolNoTag(false); + cosBool.flush(); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baosBool.toByteArray())), + FieldLiteDescriptor.Type.BOOL.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(true, false)); + + ByteArrayOutputStream baosFloat = new ByteArrayOutputStream(); + CodedOutputStream cosFloat = CodedOutputStream.newInstance(baosFloat); + cosFloat.writeFloatNoTag(1.5f); + cosFloat.flush(); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baosFloat.toByteArray())), + FieldLiteDescriptor.Type.FLOAT.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(1.5d)); + + ByteArrayOutputStream baosDouble = new ByteArrayOutputStream(); + CodedOutputStream cosDouble = CodedOutputStream.newInstance(baosDouble); + cosDouble.writeDoubleNoTag(3.14d); + cosDouble.flush(); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baosDouble.toByteArray())), + FieldLiteDescriptor.Type.DOUBLE.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(3.14d)); + + ByteArrayOutputStream baosEnum = new ByteArrayOutputStream(); + CodedOutputStream cosEnum = CodedOutputStream.newInstance(baosEnum); + cosEnum.writeEnumNoTag(2); + cosEnum.flush(); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baosEnum.toByteArray())), + FieldLiteDescriptor.Type.ENUM.getNumber(), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(2L)); + } + + @Test + public void decodeWireValue_allScalarWireTypes() { + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + Double.doubleToRawLongBits(2.5d), WireFormat.FieldType.DOUBLE, "custom.Message")) + .isEqualTo(2.5d); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + Float.floatToRawIntBits(1.5f), WireFormat.FieldType.FLOAT, "custom.Message")) + .isEqualTo(1.5d); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 42L, WireFormat.FieldType.INT64, "custom.Message")) + .isEqualTo(42L); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 42L, WireFormat.FieldType.INT32, "custom.Message")) + .isEqualTo(42L); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 42L, WireFormat.FieldType.UINT64, "custom.Message")) + .isEqualTo(UnsignedLong.fromLongBits(42L)); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 42L, WireFormat.FieldType.UINT32, "custom.Message")) + .isEqualTo(UnsignedLong.fromLongBits(42L)); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 100, WireFormat.FieldType.FIXED32, "custom.Message")) + .isEqualTo(UnsignedLong.fromLongBits(100L)); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 100L, WireFormat.FieldType.FIXED64, "custom.Message")) + .isEqualTo(UnsignedLong.fromLongBits(100L)); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + -50, WireFormat.FieldType.SFIXED32, "custom.Message")) + .isEqualTo(-50L); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + -50L, WireFormat.FieldType.SFIXED64, "custom.Message")) + .isEqualTo(-50L); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 1L, WireFormat.FieldType.BOOL, "custom.Message")) + .isEqualTo(true); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 0L, WireFormat.FieldType.BOOL, "custom.Message")) + .isEqualTo(false); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + ByteString.copyFromUtf8("hello"), WireFormat.FieldType.STRING, "custom.Message")) + .isEqualTo("hello"); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + ByteString.copyFromUtf8("bytes"), WireFormat.FieldType.BYTES, "custom.Message")) + .isEqualTo(CelByteString.of("bytes".getBytes(UTF_8))); + + Object submessage = + RawProtoMessageLiteValue.decodeWireValue( + ByteString.copyFromUtf8("raw"), WireFormat.FieldType.MESSAGE, "sub.Message"); + assertThat(submessage).isInstanceOf(RawProtoMessageLiteValue.class); + assertThat(((RawProtoMessageLiteValue) submessage).celType().name()).isEqualTo("sub.Message"); + + Object group = + RawProtoMessageLiteValue.decodeWireValue( + ByteString.copyFromUtf8("raw"), WireFormat.FieldType.GROUP, "group.Message"); + assertThat(group).isInstanceOf(RawProtoMessageLiteValue.class); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 1L, // zigzag 1 -> -1 + WireFormat.FieldType.SINT32, + "custom.Message")) + .isEqualTo(-1L); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 1L, // zigzag 1 -> -1 + WireFormat.FieldType.SINT64, + "custom.Message")) + .isEqualTo(-1L); + + assertThat( + RawProtoMessageLiteValue.decodeWireValue( + 3L, WireFormat.FieldType.ENUM, "custom.Message")) + .isEqualTo(3L); + } + + @Test + public void decodeWireValue_invalidTypeCode_throws() { + assertThrows( + IllegalArgumentException.class, + () -> RawProtoMessageLiteValue.decodeWireValue(42L, 0, "custom.Message")); + + assertThrows( + IllegalArgumentException.class, + () -> RawProtoMessageLiteValue.decodeWireValue(42L, 999, "custom.Message")); + } +} diff --git a/compiler/src/main/java/dev/cel/compiler/tools/BUILD.bazel b/compiler/src/main/java/dev/cel/compiler/tools/BUILD.bazel index 92375ec8f..9a375bc68 100644 --- a/compiler/src/main/java/dev/cel/compiler/tools/BUILD.bazel +++ b/compiler/src/main/java/dev/cel/compiler/tools/BUILD.bazel @@ -1,19 +1,18 @@ -load("@rules_java//java:defs.bzl", "java_binary") +load("@rules_java//java:defs.bzl", "java_binary", "java_library") package( default_applicable_licenses = [ "//:license", ], default_visibility = [ + "//compiler/src/test/java/dev/cel/compiler/tools:__pkg__", "//compiler/tools:__pkg__", ], ) -java_binary( - name = "cel_compiler_tool", +java_library( + name = "tools", srcs = ["CelCompilerTool.java"], - main_class = "dev.cel.compiler.tools.CelCompilerTool", - neverlink = 1, deps = [ "//bundle:environment", "//bundle:environment_exception", @@ -24,10 +23,24 @@ java_binary( "//common:proto_ast", "//compiler", "//compiler:compiler_builder", + "//optimizer", + "//optimizer:ast_optimizer", + "//optimizer:optimizer_builder", + "//optimizer/optimizers:common_subexpression_elimination", + "//optimizer/optimizers:constant_folding", + "//optimizer/optimizers:select_optimizer", "//parser:macro", + "//runtime", "@cel_spec//proto/cel/expr:checked_java_proto", "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", "@maven//:info_picocli_picocli", ], ) + +java_binary( + name = "cel_compiler_tool", + main_class = "dev.cel.compiler.tools.CelCompilerTool", + neverlink = 1, + runtime_deps = [":tools"], +) diff --git a/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java b/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java index f1d2d4f4b..860e91e2e 100644 --- a/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java +++ b/compiler/src/main/java/dev/cel/compiler/tools/CelCompilerTool.java @@ -17,6 +17,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import dev.cel.expr.CheckedExpr; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.io.Files; import com.google.protobuf.DescriptorProtos.FileDescriptorSet; @@ -32,7 +33,16 @@ import dev.cel.compiler.CelCompiler; import dev.cel.compiler.CelCompilerBuilder; import dev.cel.compiler.CelCompilerFactory; +import dev.cel.optimizer.CelAstOptimizer; +import dev.cel.optimizer.CelOptimizer; +import dev.cel.optimizer.CelOptimizerFactory; +import dev.cel.optimizer.optimizers.ConstantFoldingOptimizer; +import dev.cel.optimizer.optimizers.SelectOptimizer; +import dev.cel.optimizer.optimizers.SelectOptimizer.SelectOptimizerOptions; +import dev.cel.optimizer.optimizers.SubexpressionOptimizer; +import dev.cel.optimizer.optimizers.SubexpressionOptimizer.SubexpressionOptimizerOptions; import dev.cel.parser.CelStandardMacro; +import dev.cel.runtime.CelRuntimeFactory; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -69,6 +79,21 @@ final class CelCompilerTool implements Callable { description = "Output path for the compiled binarypb") private String output = ""; + @Option( + names = {"--constant_folding"}, + description = "Enable constant folding optimization on the compiled AST") + private boolean constantFolding = false; + + @Option( + names = {"--subexpression_elimination"}, + description = "Enable common subexpression elimination on the compiled AST") + private boolean subexpressionElimination = false; + + @Option( + names = {"--optimize_field_selection"}, + description = "Optimize field selection for version skew mitigation") + private boolean optimizeFieldSelection = false; + private static final CelOptions CEL_OPTIONS = CelOptions.DEFAULT; private static CelCompiler prepareCompiler( @@ -140,6 +165,35 @@ public Integer call() { try { CelAbstractSyntaxTree ast = celCompiler.compile(celExpression).getAst(); + ImmutableList.Builder optimizers = ImmutableList.builder(); + if (constantFolding) { + optimizers.add(ConstantFoldingOptimizer.getInstance()); + } + if (subexpressionElimination) { + optimizers.add( + SubexpressionOptimizer.newInstance( + SubexpressionOptimizerOptions.newBuilder().populateMacroCalls(true).build())); + } + if (optimizeFieldSelection) { + SelectOptimizerOptions.Builder optionsBuilder = SelectOptimizerOptions.newBuilder(); + if (!transitiveDescriptorSetPath.isEmpty()) { + ImmutableSet transitiveFileDescriptors = + CelDescriptorUtil.getFileDescriptorsFromFileDescriptorSet( + load(transitiveDescriptorSetPath)); + optionsBuilder.addFileDescriptors(transitiveFileDescriptors); + } + optimizers.add(SelectOptimizer.newInstance(optionsBuilder.build())); + } + + ImmutableList astOptimizers = optimizers.build(); + if (!astOptimizers.isEmpty()) { + CelOptimizer celOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder( + celCompiler, CelRuntimeFactory.standardCelRuntimeBuilder().build()) + .addAstOptimizers(astOptimizers) + .build(); + ast = celOptimizer.optimize(ast); + } writeCheckedExpr(ast, output); } catch (Exception e) { String errorMessage = diff --git a/compiler/src/test/java/dev/cel/compiler/tools/BUILD.bazel b/compiler/src/test/java/dev/cel/compiler/tools/BUILD.bazel index c388ea3ea..ffd484aed 100644 --- a/compiler/src/test/java/dev/cel/compiler/tools/BUILD.bazel +++ b/compiler/src/test/java/dev/cel/compiler/tools/BUILD.bazel @@ -13,15 +13,20 @@ java_library( deps = [ "//:java_truth", "//common:cel_ast", + "//common:cel_source", "//common:options", + "//common:proto_ast", + "//compiler/src/main/java/dev/cel/compiler/tools", "//extensions", "//extensions:optional_library", "//runtime", "//runtime:function_binding", "//testing/compiled:compiled_expr_utils", + "@cel_spec//proto/cel/expr:checked_java_proto", "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto", "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", + "@maven//:info_picocli_picocli", "@maven//:junit_junit", ], ) diff --git a/compiler/src/test/java/dev/cel/compiler/tools/CelCompilerToolTest.java b/compiler/src/test/java/dev/cel/compiler/tools/CelCompilerToolTest.java index ed3d8b473..6e93d2d0e 100644 --- a/compiler/src/test/java/dev/cel/compiler/tools/CelCompilerToolTest.java +++ b/compiler/src/test/java/dev/cel/compiler/tools/CelCompilerToolTest.java @@ -17,30 +17,42 @@ import static com.google.common.truth.Truth.assertThat; import static dev.cel.testing.compiled.CompiledExprUtils.readCheckedExpr; +import dev.cel.expr.CheckedExpr; import com.google.common.collect.ImmutableMap; +import com.google.common.io.Files; +import com.google.protobuf.ExtensionRegistryLite; import com.google.protobuf.StringValue; import dev.cel.common.CelAbstractSyntaxTree; import dev.cel.common.CelOptions; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.CelSource; import dev.cel.expr.conformance.proto3.TestAllTypes; import dev.cel.extensions.CelExtensions; import dev.cel.extensions.CelOptionalLibrary; -import dev.cel.runtime.CelRuntime; import dev.cel.runtime.CelFunctionBinding; +import dev.cel.runtime.CelRuntime; import dev.cel.runtime.CelRuntimeFactory; +import java.io.File; import java.util.List; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import picocli.CommandLine; @RunWith(JUnit4.class) public class CelCompilerToolTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + private static final CelRuntime CEL_RUNTIME = CelRuntimeFactory.standardCelRuntimeBuilder() .addFunctionBindings( CelFunctionBinding.from("wrapper_string_isEmpty", String.class, String::isEmpty)) .addLibraries( - CelExtensions.encoders(), - CelExtensions.math(CelOptions.DEFAULT), + CelExtensions.encoders(CelOptions.DEFAULT), + CelExtensions.math(), CelExtensions.lists(), CelExtensions.strings(), CelOptionalLibrary.INSTANCE) @@ -56,6 +68,7 @@ public void compiledCheckedExpr_string() throws Exception { } @Test + // Evaluated comprehension returns an unparameterized List @SuppressWarnings("unchecked") public void compiledCheckedExpr_comprehension() throws Exception { CelAbstractSyntaxTree ast = readCheckedExpr("compiled_comprehension"); @@ -96,4 +109,154 @@ public void compiledCheckedExpr_extended_env() throws Exception { assertThat(result).isTrue(); } + + @Test + public void compiledCheckedExpr_withSelectOptimization() throws Exception { + CelAbstractSyntaxTree ast = readCheckedExpr("compiled_proto3_select_primitives_optimized"); + + assertThat(ast.getSource().getExtensions()) + .contains( + CelSource.Extension.create( + "select_optimization", + CelSource.Extension.Version.of(1L, 0L), + CelSource.Extension.Component.COMPONENT_RUNTIME)); + } + + @Test + public void compiledCheckedExpr_withConstantFolding() throws Exception { + CelAbstractSyntaxTree ast = readCheckedExpr("compiled_constant_folding"); + + assertThat(ast.getExpr().constantOrDefault().int64Value()).isEqualTo(6L); + } + + @Test + public void compiledCheckedExpr_withSubexpressionElimination() throws Exception { + CelAbstractSyntaxTree ast = readCheckedExpr("compiled_subexpression_elimination"); + + assertThat(ast.getExpr().call().function()).isEqualTo("cel.@block"); + assertThat(CEL_RUNTIME.createProgram(ast).eval()).isEqualTo(true); + } + + @Test + public void compile_tool_direct_default_success() throws Exception { + File outputFile = tempFolder.newFile("direct_output.binarypb"); + + CommandLine cmd = new CommandLine(new CelCompilerTool()); + int exitCode = + cmd.execute( + "--cel_expression", "\"hello world\"", "--output", outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + assertThat(CEL_RUNTIME.createProgram(ast).eval()).isEqualTo("hello world"); + } + + @Test + public void compile_tool_direct_constantFolding_success() throws Exception { + File outputFile = tempFolder.newFile("direct_cf.binarypb"); + + CommandLine cmd = new CommandLine(new CelCompilerTool()); + int exitCode = + cmd.execute( + "--cel_expression", + "1 + 2 + 3", + "--constant_folding", + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + assertThat(ast.getExpr().constantOrDefault().int64Value()).isEqualTo(6L); + } + + @Test + public void compile_tool_direct_subexpressionElimination_success() throws Exception { + File outputFile = tempFolder.newFile("direct_cse.binarypb"); + + CommandLine cmd = new CommandLine(new CelCompilerTool()); + int exitCode = + cmd.execute( + "--cel_expression", + "size('a') + size('a') == 2", + "--subexpression_elimination", + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + assertThat(ast.getExpr().call().function()).isEqualTo("cel.@block"); + } + + @Test + public void compile_tool_direct_optimizeFieldSelection_withoutDescriptors_success() + throws Exception { + File outputFile = tempFolder.newFile("direct_opt.binarypb"); + + CommandLine cmd = new CommandLine(new CelCompilerTool()); + int exitCode = + cmd.execute( + "--cel_expression", + "true", + "--optimize_field_selection", + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + } + + @Test + public void compile_tool_direct_error_invalidExpression_returnsErrorCode() throws Exception { + File outputFile = tempFolder.newFile("direct_err.binarypb"); + + CommandLine cmd = new CommandLine(new CelCompilerTool()); + int exitCode = cmd.execute("--cel_expression", "1 +", "--output", outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_tool_direct_error_invalidEnvironmentPath_returnsErrorCode() throws Exception { + File outputFile = tempFolder.newFile("direct_err_env.binarypb"); + + CommandLine cmd = new CommandLine(new CelCompilerTool()); + int exitCode = + cmd.execute( + "--cel_expression", + "true", + "--environment_path", + "non_existent_env.yaml", + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_tool_direct_error_invalidDescriptorPath_returnsErrorCode() throws Exception { + File outputFile = tempFolder.newFile("direct_err_desc.binarypb"); + + CommandLine cmd = new CommandLine(new CelCompilerTool()); + int exitCode = + cmd.execute( + "--cel_expression", + "true", + "--transitive_descriptor_set", + "non_existent_desc.pb", + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } } diff --git a/compiler/tools/compile_cel.bzl b/compiler/tools/compile_cel.bzl index a428850f9..f2923bc28 100644 --- a/compiler/tools/compile_cel.bzl +++ b/compiler/tools/compile_cel.bzl @@ -21,7 +21,11 @@ def compile_cel( expression, proto_srcs = [], environment = None, - output = None): + output = None, + optimize_field_selection = False, + constant_folding = False, + subexpression_elimination = False, + visibility = None): """Compiles a CEL expression, generating a cel.expr.CheckedExpr proto. This proto is written to a `.binarypb` file. Args: @@ -30,6 +34,10 @@ def compile_cel( proto_srcs: (optional) list of str label(s) pointing to a proto_library rule (important: NOT java_proto_library). This must be provided when compiling a CEL expression containing protobuf messages. environment: (optional) str label or filename pointing to a YAML file that describes a CEL environment. output: (optional) str file name for the output checked expression. `.binarypb` extension is automatically appended in the filename. + optimize_field_selection: (optional) bool whether to optimize field selection for version skew mitigation. + constant_folding: (optional) bool whether to enable constant folding on the compiled AST. + subexpression_elimination: (optional) bool whether to enable common subexpression elimination on the compiled AST. + visibility: (optional) visibility to use on the genrule macro. """ args = [] @@ -56,6 +64,15 @@ def compile_cel( args.append("--environment_path=$(location {})".format(environment)) genrule_srcs.append(environment) + if optimize_field_selection: + args.append("--optimize_field_selection") + + if constant_folding: + args.append("--constant_folding") + + if subexpression_elimination: + args.append("--subexpression_elimination") + arg_str = " ".join(args) cmd = ( "$(location //compiler/tools:cel_compiler_tool) " + @@ -68,4 +85,5 @@ def compile_cel( srcs = genrule_srcs, outs = [output], tools = ["//compiler/tools:cel_compiler_tool"], + visibility = visibility, ) diff --git a/optimizer/optimizers/BUILD.bazel b/optimizer/optimizers/BUILD.bazel index 26d98c574..e95d48728 100644 --- a/optimizer/optimizers/BUILD.bazel +++ b/optimizer/optimizers/BUILD.bazel @@ -19,3 +19,8 @@ java_library( name = "inlining", exports = ["//optimizer/src/main/java/dev/cel/optimizer/optimizers:inlining"], ) + +java_library( + name = "select_optimizer", + exports = ["//optimizer/src/main/java/dev/cel/optimizer/optimizers:select_optimizer"], +) diff --git a/optimizer/src/main/java/dev/cel/optimizer/optimizers/BUILD.bazel b/optimizer/src/main/java/dev/cel/optimizer/optimizers/BUILD.bazel index 0e6509c44..e5201994a 100644 --- a/optimizer/src/main/java/dev/cel/optimizer/optimizers/BUILD.bazel +++ b/optimizer/src/main/java/dev/cel/optimizer/optimizers/BUILD.bazel @@ -111,6 +111,40 @@ java_library( ], ) +java_library( + name = "select_optimizer", + srcs = [ + "SelectOptimizer.java", + ], + tags = [ + ], + deps = [ + "//:auto_value", + "//bundle:cel", + "//common:cel_ast", + "//common:cel_descriptor_util", + "//common:cel_descriptors", + "//common:cel_source", + "//common:compiler_common", + "//common:mutable_ast", + "//common/ast", + "//common/ast:mutable_expr", + "//common/internal:cel_descriptor_pools", + "//common/navigation:common", + "//common/navigation:mutable_navigation", + "//common/types", + "//common/types:type_providers", + "//common/values", + "//common/values:cel_byte_string", + "//optimizer:ast_optimizer", + "//optimizer:mutable_ast", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", + "@maven//:com_google_protobuf_protobuf_java", + "@maven//:org_jspecify_jspecify", + ], +) + java_library( name = "default_optimizer_constants", srcs = [ diff --git a/optimizer/src/main/java/dev/cel/optimizer/optimizers/SelectOptimizer.java b/optimizer/src/main/java/dev/cel/optimizer/optimizers/SelectOptimizer.java new file mode 100644 index 000000000..0b0dec4ff --- /dev/null +++ b/optimizer/src/main/java/dev/cel/optimizer/optimizers/SelectOptimizer.java @@ -0,0 +1,477 @@ +// 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.optimizer.optimizers; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.google.auto.value.AutoValue; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.UnsignedLong; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FileDescriptor; +import dev.cel.bundle.Cel; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelDescriptorUtil; +import dev.cel.common.CelDescriptors; +import dev.cel.common.CelFunctionDecl; +import dev.cel.common.CelMutableAst; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.CelSource; +import dev.cel.common.CelSource.Extension; +import dev.cel.common.CelSource.Extension.Component; +import dev.cel.common.CelSource.Extension.Version; +import dev.cel.common.ast.CelConstant; +import dev.cel.common.ast.CelExpr.ExprKind.Kind; +import dev.cel.common.ast.CelMutableExpr; +import dev.cel.common.ast.CelMutableExpr.CelMutableCall; +import dev.cel.common.ast.CelMutableExpr.CelMutableList; +import dev.cel.common.ast.CelMutableExpr.CelMutableMap; +import dev.cel.common.ast.CelMutableExpr.CelMutableSelect; +import dev.cel.common.internal.CelDescriptorPool; +import dev.cel.common.internal.CombinedDescriptorPool; +import dev.cel.common.internal.DefaultDescriptorPool; +// CEL-Internal-1 +import dev.cel.common.navigation.CelNavigableMutableAst; +import dev.cel.common.navigation.CelNavigableMutableExpr; +import dev.cel.common.navigation.TraversalOrder; +import dev.cel.common.types.CelKind; +import dev.cel.common.types.ListType; +import dev.cel.common.types.SimpleType; +import dev.cel.common.values.CelByteString; +import dev.cel.common.values.NullValue; +import dev.cel.optimizer.AstMutator; +import dev.cel.optimizer.CelAstOptimizer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Performs field selection optimization on protobuf message select chains. + * + *

Embeds protobuf field metadata (field number, field name, type code, default value) directly + * into qualification paths ({@code cel.@attribute} and {@code cel.@hasField}). This accelerates + * nested field evaluation, enables reflection-free field traversal in resource-constrained runtimes + * without descriptor tables, and provides resilience against protobuf field renames. + * + *

Trade-off: Modestly increases serialized AST size over the wire due to the embedded + * metadata tuples. + * + *

AST Rewriting Semantics

+ * + *
    + *
  • Selection chains: {@code request.user.age} → {@code cel.@attribute(request, + * [[user_num, "user", type_code, default_val], [age_num, "age", type_code, default_val]])} + *
  • Presence tests: {@code has(request.user.age)} → {@code cel.@hasField(request, + * [[user_num, "user"], [age_num, "age"]])} + *
+ * + *

Map indexing and non-protobuf selects pass through untouched. + */ +public final class SelectOptimizer implements CelAstOptimizer { + + private static final String CEL_ATTRIBUTE_FUNCTION_NAME = "cel.@attribute"; + private static final String CEL_HAS_FIELD_FUNCTION_NAME = "cel.@hasField"; + + @VisibleForTesting + static final CelFunctionDecl CEL_ATTRIBUTE_FUNCTION_DECL = + CelFunctionDecl.newFunctionDeclaration( + CEL_ATTRIBUTE_FUNCTION_NAME, + CelOverloadDecl.newGlobalOverload( + "cel_attribute_list", + SimpleType.DYN, + SimpleType.DYN, + ListType.create(SimpleType.DYN))); + + @VisibleForTesting + static final CelFunctionDecl CEL_HAS_FIELD_FUNCTION_DECL = + CelFunctionDecl.newFunctionDeclaration( + CEL_HAS_FIELD_FUNCTION_NAME, + CelOverloadDecl.newGlobalOverload( + "cel_has_field_list", + SimpleType.BOOL, + SimpleType.DYN, + ListType.create(SimpleType.DYN))); + + @VisibleForTesting + static final Extension SELECT_OPTIMIZATION_AST_EXTENSION_TAG = + Extension.create("select_optimization", Version.of(1L, 0L), Component.COMPONENT_RUNTIME); + + private static final SelectOptimizer INSTANCE = + new SelectOptimizer(SelectOptimizerOptions.newBuilder().build()); + + private final SelectOptimizerOptions options; + private final AstMutator astMutator; + + /** Returns a default instance of the select optimizer with preconfigured defaults. */ + public static SelectOptimizer getInstance() { + return INSTANCE; + } + + /** Returns a new select optimizer configured with the provided options. */ + public static SelectOptimizer newInstance(SelectOptimizerOptions options) { + return new SelectOptimizer(options); + } + + /** Returns a new select optimizer configured with the provided options and file descriptors. */ + public static SelectOptimizer newInstance( + SelectOptimizerOptions options, FileDescriptor... fileDescriptors) { + return newInstance(options, Arrays.asList(checkNotNull(fileDescriptors))); + } + + /** Returns a new select optimizer configured with the provided options and file descriptors. */ + public static SelectOptimizer newInstance( + SelectOptimizerOptions options, Iterable fileDescriptors) { + return new SelectOptimizer( + checkNotNull(options).toBuilder().addFileDescriptors(fileDescriptors).build()); + } + + private SelectOptimizer(SelectOptimizerOptions options) { + this.options = checkNotNull(options); + this.astMutator = AstMutator.newInstance(options.iterationLimit()); + } + + @Override + public OptimizationResult optimize(CelAbstractSyntaxTree ast, Cel cel) { + checkArgument(ast.isChecked(), "AST must be type-checked."); + + CelMutableAst astToModify = CelMutableAst.fromCelAst(ast); + if (!options.populateMacroCalls()) { + astToModify.source().clearMacroCalls(); + } + + CelNavigableMutableAst navAst = CelNavigableMutableAst.fromAst(astToModify); + ImmutableList topOfChainSelects = + navAst + .getRoot() + .allNodes(TraversalOrder.POST_ORDER) + .filter(node -> isTopOfSelectChain(navAst, node)) + .collect(toImmutableList()); + + if (topOfChainSelects.isEmpty()) { + if (!options.populateMacroCalls() && !ast.getSource().getMacroCalls().isEmpty()) { + return OptimizationResult.create(astToModify.toParsedAst()); + } + return OptimizationResult.create(ast); + } + + long maxId = navAst.getRoot().allNodes().mapToLong(node -> node.expr().id()).max().orElse(0L); + AtomicLong idCounter = new AtomicLong(maxId); + + for (CelNavigableMutableExpr topNode : topOfChainSelects) { + rewriteSelectChain(astToModify, navAst, topNode, idCounter); + } + + astToModify = astMutator.renumberIdsConsecutively(astToModify); + CelAbstractSyntaxTree optimizedAst = tagAstExtension(astToModify.toParsedAst()); + + return OptimizationResult.create( + optimizedAst, + ImmutableList.of(), + ImmutableList.of(CEL_ATTRIBUTE_FUNCTION_DECL, CEL_HAS_FIELD_FUNCTION_DECL)); + } + + private void rewriteSelectChain( + CelMutableAst astToModify, + CelNavigableMutableAst navAst, + CelNavigableMutableExpr topNode, + AtomicLong idCounter) { + boolean isHasField = topNode.expr().select().testOnly(); + astToModify.source().getMacroCalls().remove(topNode.expr().id()); + + List fields = new ArrayList<>(); + FieldDescriptor topField = + getOptimizableField(navAst, topNode) + .orElseThrow( + () -> new IllegalStateException("Expected optimizable field on select node")); + fields.add(topField); + + CelMutableExpr currentExpr = topNode.expr().select().operand(); + while (currentExpr.getKind() == Kind.SELECT) { + CelMutableSelect select = currentExpr.select(); + FieldDescriptor field = getOptimizableFieldForExpr(navAst, select).orElse(null); + if (field == null) { + break; + } + fields.add(field); + currentExpr = select.operand(); + } + + Collections.reverse(fields); + + List qualifierLists = new ArrayList<>(fields.size()); + for (FieldDescriptor field : fields) { + if (isHasField) { + qualifierLists.add( + CelMutableExpr.ofList( + idCounter.incrementAndGet(), + CelMutableList.create( + CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue((long) field.getNumber())), + CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue(field.getName()))))); + } else { + CelMutableExpr defaultValue = resolveDefaultValue(field, idCounter); + qualifierLists.add( + CelMutableExpr.ofList( + idCounter.incrementAndGet(), + CelMutableList.create( + CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue((long) field.getNumber())), + CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue(field.getName())), + CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), + CelConstant.ofValue((long) field.getType().toProto().getNumber())), + defaultValue))); + } + } + + CelMutableExpr qualifiersExpr = + CelMutableExpr.ofList(idCounter.incrementAndGet(), CelMutableList.create(qualifierLists)); + String functionName = isHasField ? CEL_HAS_FIELD_FUNCTION_NAME : CEL_ATTRIBUTE_FUNCTION_NAME; + topNode.expr().setCall(CelMutableCall.create(functionName, currentExpr, qualifiersExpr)); + } + + private boolean isTopOfSelectChain(CelNavigableMutableAst navAst, CelNavigableMutableExpr node) { + return getOptimizableField(navAst, node).isPresent() + && !node.parent().flatMap(parent -> getOptimizableField(navAst, parent)).isPresent(); + } + + private Optional getOptimizableField( + CelNavigableMutableAst navAst, CelNavigableMutableExpr node) { + if (node.getKind() != Kind.SELECT) { + return Optional.empty(); + } + return getOptimizableFieldForExpr(navAst, node.expr().select()); + } + + private Optional getOptimizableFieldForExpr( + CelNavigableMutableAst navAst, CelMutableSelect select) { + return navAst + .getType(select.operand().id()) + .filter(type -> type.kind() == CelKind.STRUCT) + .flatMap(type -> options.descriptorPool().findDescriptor(type.name())) + .map(desc -> desc.findFieldByName(select.field())); + } + + private static CelMutableExpr resolveDefaultValue(FieldDescriptor field, AtomicLong idCounter) { + if (field.isMapField()) { + return CelMutableExpr.ofMap( + idCounter.incrementAndGet(), CelMutableMap.create(ImmutableList.of())); + } + if (field.isRepeated()) { + return CelMutableExpr.ofList(idCounter.incrementAndGet(), CelMutableList.create()); + } + if (field.getType() == FieldDescriptor.Type.MESSAGE + || field.getType() == FieldDescriptor.Type.GROUP) { + String messageFullName = field.getMessageType().getFullName(); + switch (messageFullName) { + case "google.protobuf.Duration": + return CelMutableExpr.ofCall( + idCounter.incrementAndGet(), + CelMutableCall.create( + "duration", + CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue("0s")))); + case "google.protobuf.Timestamp": + return CelMutableExpr.ofCall( + idCounter.incrementAndGet(), + CelMutableCall.create( + "timestamp", + CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue("1970-01-01T00:00:00Z")))); + case "google.protobuf.Struct": + return CelMutableExpr.ofMap( + idCounter.incrementAndGet(), CelMutableMap.create(ImmutableList.of())); + case "google.protobuf.ListValue": + return CelMutableExpr.ofList(idCounter.incrementAndGet(), CelMutableList.create()); + default: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue(NullValue.NULL_VALUE)); + } + } + + Object def = field.getDefaultValue(); + switch (field.getType()) { + case DOUBLE: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue((Double) def)); + case FLOAT: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue(((Float) def).doubleValue())); + case INT64: + case SINT64: + case SFIXED64: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue((Long) def)); + case UINT64: + case FIXED64: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), + CelConstant.ofValue(UnsignedLong.fromLongBits((Long) def))); + case INT32: + case SINT32: + case SFIXED32: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue(((Integer) def).longValue())); + case UINT32: + case FIXED32: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), + CelConstant.ofValue(UnsignedLong.fromLongBits(Integer.toUnsignedLong((Integer) def)))); + case BOOL: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue((Boolean) def)); + case STRING: + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue((String) def)); + case BYTES: + ByteString byteString = (ByteString) def; + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), + byteString.isEmpty() + ? CelConstant.ofValue(CelByteString.EMPTY) + : CelConstant.ofValue(CelByteString.of(byteString.toByteArray()))); + case ENUM: + EnumValueDescriptor enumValue = (EnumValueDescriptor) def; + return CelMutableExpr.ofConstant( + idCounter.incrementAndGet(), CelConstant.ofValue((long) enumValue.getNumber())); + default: + throw new IllegalArgumentException("Unsupported protobuf field type: " + field.getType()); + } + } + + private static CelAbstractSyntaxTree tagAstExtension(CelAbstractSyntaxTree ast) { + CelSource.Builder celSourceBuilder = + ast.getSource().toBuilder().addAllExtensions(SELECT_OPTIMIZATION_AST_EXTENSION_TAG); + return CelAbstractSyntaxTree.newParsedAst(ast.getExpr(), celSourceBuilder.build()); + } + + /** Options configuring the behavior of {@link SelectOptimizer}. */ + @AutoValue + public abstract static class SelectOptimizerOptions { + + public abstract int iterationLimit(); + + public abstract boolean populateMacroCalls(); + + abstract CelDescriptorPool descriptorPool(); + + /** Builder for configuring {@link SelectOptimizerOptions}. */ + @AutoValue.Builder + public abstract static class Builder { + + public abstract Builder iterationLimit(int value); + + public abstract Builder populateMacroCalls(boolean value); + + abstract Builder descriptorPool(CelDescriptorPool descriptorPool); + + abstract Optional descriptorPool(); + + private final List fileDescriptors; + private boolean linkedMessageTypesEnabled; + + /** + * Sets whether to resolve compiled linked message types in the descriptor pool. + * + *

Note: This setting is only applied when the initial descriptor pool is constructed. It + * has no effect when configuring an options instance from {@link #toBuilder()} whose + * descriptor pool has already been initialized. + */ + @CanIgnoreReturnValue + public Builder enableLinkedMessageTypes(boolean enable) { + this.linkedMessageTypesEnabled = enable; + return this; + } + + /** Adds file descriptors to the descriptor pool. */ + @CanIgnoreReturnValue + public Builder addFileDescriptors(FileDescriptor... fileDescriptors) { + return addFileDescriptors(Arrays.asList(checkNotNull(fileDescriptors))); + } + + /** Adds file descriptors to the descriptor pool. */ + @CanIgnoreReturnValue + public Builder addFileDescriptors(Iterable fileDescriptors) { + checkNotNull(fileDescriptors); + for (FileDescriptor fileDescriptor : fileDescriptors) { + this.fileDescriptors.add(checkNotNull(fileDescriptor)); + } + return this; + } + + abstract SelectOptimizerOptions autoBuild(); + + public SelectOptimizerOptions build() { + CelDescriptorPool pool = + descriptorPool() + .map( + existingPool -> { + if (fileDescriptors.isEmpty()) { + return existingPool; + } + CelDescriptors descriptors = + CelDescriptorUtil.getAllDescriptorsFromFileDescriptor(fileDescriptors); + fileDescriptors.clear(); + return CombinedDescriptorPool.create( + ImmutableList.of( + DefaultDescriptorPool.create(descriptors), existingPool)); + }) + .orElseGet( + () -> { + ImmutableList.Builder pools = ImmutableList.builder(); + if (!fileDescriptors.isEmpty()) { + CelDescriptors descriptors = + CelDescriptorUtil.getAllDescriptorsFromFileDescriptor(fileDescriptors); + pools.add(DefaultDescriptorPool.create(descriptors)); + } + + pools.add(DefaultDescriptorPool.INSTANCE); + fileDescriptors.clear(); + return CombinedDescriptorPool.create(pools.build()); + }); + descriptorPool(pool); + return autoBuild(); + } + + Builder() { + this.fileDescriptors = new ArrayList<>(); + this.linkedMessageTypesEnabled = true; + } + } + + abstract Builder toBuilder(); + + /** Returns a new options builder with recommended defaults. */ + public static Builder newBuilder() { + return new AutoValue_SelectOptimizer_SelectOptimizerOptions.Builder() + .iterationLimit(500) + .populateMacroCalls(true); + } + + // Package-private constructor to prevent external extension, required by @AutoValue. + SelectOptimizerOptions() {} + } +} diff --git a/optimizer/src/test/java/dev/cel/optimizer/optimizers/BUILD.bazel b/optimizer/src/test/java/dev/cel/optimizer/optimizers/BUILD.bazel index c912d9570..787012466 100644 --- a/optimizer/src/test/java/dev/cel/optimizer/optimizers/BUILD.bazel +++ b/optimizer/src/test/java/dev/cel/optimizer/optimizers/BUILD.bazel @@ -18,6 +18,7 @@ java_library( "//common:container", "//common:mutable_ast", "//common:options", + "//common:proto_ast", "//common/ast", "//common/navigation:mutable_navigation", "//common/types", @@ -30,6 +31,7 @@ java_library( "//optimizer/optimizers:common_subexpression_elimination", "//optimizer/optimizers:constant_folding", "//optimizer/optimizers:inlining", + "//optimizer/optimizers:select_optimizer", "//parser:macro", "//parser:unparser", "//runtime", @@ -42,6 +44,8 @@ java_library( "@maven//:junit_junit", "@maven//:com_google_testparameterinjector_test_parameter_injector", "//:java_truth", + "@maven//:com_google_truth_extensions_truth_proto_extension", + "@cel_spec//proto/cel/expr:syntax_java_proto", "@cel_spec//proto/cel/expr/conformance/proto2:test_all_types_java_proto", "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto", "@maven//:com_google_guava_guava", diff --git a/optimizer/src/test/java/dev/cel/optimizer/optimizers/SelectOptimizerTest.java b/optimizer/src/test/java/dev/cel/optimizer/optimizers/SelectOptimizerTest.java new file mode 100644 index 000000000..9e7015486 --- /dev/null +++ b/optimizer/src/test/java/dev/cel/optimizer/optimizers/SelectOptimizerTest.java @@ -0,0 +1,765 @@ +// 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.optimizer.optimizers; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat; +import static org.junit.Assert.assertThrows; + +import dev.cel.expr.ParsedExpr; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FileDescriptor; +import com.google.protobuf.TextFormat; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import dev.cel.bundle.Cel; +import dev.cel.bundle.CelBuilder; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelFunctionDecl; +import dev.cel.common.CelMutableAst; +import dev.cel.common.CelOptions; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.CelValidationException; +import dev.cel.common.navigation.CelNavigableMutableAst; +import dev.cel.common.types.MapType; +import dev.cel.common.types.SimpleType; +import dev.cel.common.types.StructTypeReference; +import dev.cel.expr.conformance.proto2.NestedTestAllTypes; +import dev.cel.expr.conformance.proto2.TestAllTypesProto; +import dev.cel.expr.conformance.proto3.TestAllTypes; +import dev.cel.optimizer.CelOptimizer; +import dev.cel.optimizer.CelOptimizerFactory; +import dev.cel.optimizer.optimizers.SelectOptimizer.SelectOptimizerOptions; +import dev.cel.parser.CelStandardMacro; +import dev.cel.parser.CelUnparser; +import dev.cel.parser.CelUnparserFactory; +import dev.cel.runtime.CelFunctionBinding; +import dev.cel.testing.CelRuntimeFlavor; +import java.util.List; +import java.util.stream.LongStream; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class SelectOptimizerTest { + + private static final CelOptions CEL_OPTIONS = + CelOptions.current() + .populateMacroCalls(true) + .enableHeterogeneousNumericComparisons(true) + .build(); + + private static final CelUnparser CEL_UNPARSER = CelUnparserFactory.newUnparser(); + + private static final Descriptor PROTO2_TEST_ALL_TYPES_DESCRIPTOR = + checkNotNull(TestAllTypesProto.getDescriptor().findMessageTypeByName("TestAllTypes")); + + @TestParameter CelRuntimeFlavor runtimeFlavor; + + private Cel cel; + private CelOptimizer celOptimizer; + + @Before + public void setUp() { + cel = setupEnv(runtimeFlavor.builder()); + celOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers( + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile(), + PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFile(), + NestedTestAllTypes.getDescriptor().getFile())) + .build(); + } + + private static Cel setupEnv(CelBuilder celBuilder) { + return celBuilder + .setOptions(CEL_OPTIONS) + .setStandardMacros(CelStandardMacro.STANDARD_MACROS) + .addMessageTypes(TestAllTypes.getDescriptor()) + .addMessageTypes(PROTO2_TEST_ALL_TYPES_DESCRIPTOR) + .addMessageTypes(NestedTestAllTypes.getDescriptor()) + .addVar("msg", StructTypeReference.create(TestAllTypes.getDescriptor().getFullName())) + .addVar( + "proto2_msg", + StructTypeReference.create(PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFullName())) + .addVar( + "nested_msg", + StructTypeReference.create(NestedTestAllTypes.getDescriptor().getFullName())) + .addVar("map_var", MapType.create(SimpleType.STRING, SimpleType.INT)) + .addVar("x", SimpleType.INT) + .build(); + } + + private enum RewriteTestCase { + // === Selection & Traversal === + PROTO3_SINGLE_FIELD_SELECT( + "msg.single_int64", "cel.@attribute(msg, [[2, \"single_int64\", 3, 0]])"), + PROTO3_CHAINED_FIELD_SELECT( + "msg.single_nested_message.bb", + "cel.@attribute(msg, [[21, \"single_nested_message\", 11, null], [1, \"bb\", 5, 0]])"), + PROTO2_CHAINED_FIELD_SELECT( + "proto2_msg.single_nested_message.bb", + "cel.@attribute(proto2_msg, [[21, \"single_nested_message\", 11, null], [1, \"bb\", 5," + + " 0]])"), + PROTO2_TRIPLE_CHAINED_FIELD_SELECT( + "nested_msg.child.payload.single_int64", + "cel.@attribute(nested_msg, " + + "[[1, \"child\", 11, null], " + + "[2, \"payload\", 11, null], " + + "[2, \"single_int64\", 3, -64]])"), + PROTO2_GROUP_FIELD_SELECT( + "proto2_msg.nestedgroup.single_id", + "cel.@attribute(proto2_msg, [[403, \"nestedgroup\", 10, null], [404, \"single_id\", 5," + + " 0]])"), + + // === Presence Tests: Proto2 (Explicit Presence) vs Proto3 (Implicit/Explicit Presence) === + // In proto2, scalar fields have explicit presence (has-bit). + PROTO2_HAS_SCALAR_INT32( + "has(proto2_msg.single_int32)", "cel.@hasField(proto2_msg, [[1, \"single_int32\"]])"), + PROTO2_HAS_SCALAR_INT64( + "has(proto2_msg.single_int64)", "cel.@hasField(proto2_msg, [[2, \"single_int64\"]])"), + // In proto3, non-optional scalar fields have implicit presence (evaluated as != default). + PROTO3_HAS_SCALAR_INT32("has(msg.single_int32)", "cel.@hasField(msg, [[1, \"single_int32\"]])"), + PROTO3_HAS_SCALAR_INT64("has(msg.single_int64)", "cel.@hasField(msg, [[2, \"single_int64\"]])"), + // In proto3, explicit optional scalars have presence (has-bit). + PROTO3_HAS_OPTIONAL_BOOL( + "has(msg.optional_bool)", "cel.@hasField(msg, [[16, \"optional_bool\"]])"), + PROTO3_HAS_OPTIONAL_STRING( + "has(msg.optional_string)", "cel.@hasField(msg, [[17, \"optional_string\"]])"), + // Messages in both proto2 and proto3 have explicit presence. + PROTO2_HAS_MESSAGE( + "has(proto2_msg.single_nested_message)", + "cel.@hasField(proto2_msg, [[21, \"single_nested_message\"]])"), + PROTO3_HAS_MESSAGE( + "has(msg.single_nested_message)", "cel.@hasField(msg, [[21, \"single_nested_message\"]])"), + PROTO3_HAS_STANDALONE_MESSAGE( + "has(msg.standalone_message)", "cel.@hasField(msg, [[23, \"standalone_message\"]])"), + PROTO3_HAS_ONEOF_ENUM( + "has(msg.single_nested_enum)", "cel.@hasField(msg, [[22, \"single_nested_enum\"]])"), + PROTO2_HAS_CHAINED_MESSAGE( + "has(proto2_msg.single_nested_message.bb)", + "cel.@hasField(proto2_msg, [[21, \"single_nested_message\"], [1, \"bb\"]])"), + PROTO3_HAS_CHAINED_MESSAGE( + "has(msg.single_nested_message.bb)", + "cel.@hasField(msg, [[21, \"single_nested_message\"], [1, \"bb\"]])"), + PROTO2_HAS_TRIPLE_CHAINED_MESSAGE( + "has(nested_msg.child.payload.single_int64)", + "cel.@hasField(nested_msg, [[1, \"child\"], [2, \"payload\"], [2, \"single_int64\"]])"), + + // === Default Value Divergence: Proto2 Custom Defaults vs Proto3 Zero Defaults === + // Int32: proto2 has custom default -32, proto3 has 0 + PROTO2_CUSTOM_INT32( + "proto2_msg.single_int32", "cel.@attribute(proto2_msg, [[1, \"single_int32\", 5, -32]])"), + PROTO3_ZERO_INT32("msg.single_int32", "cel.@attribute(msg, [[1, \"single_int32\", 5, 0]])"), + + // Int64: proto2 has custom default -64, proto3 has 0 + PROTO2_CUSTOM_INT64( + "proto2_msg.single_int64", "cel.@attribute(proto2_msg, [[2, \"single_int64\", 3, -64]])"), + PROTO3_ZERO_INT64("msg.single_int64", "cel.@attribute(msg, [[2, \"single_int64\", 3, 0]])"), + + // Uint32: proto2 has custom default 32, proto3 has 0 + PROTO2_CUSTOM_UINT32( + "proto2_msg.single_uint32", + "cel.@attribute(proto2_msg, [[3, \"single_uint32\", 13, 32u]])"), + PROTO3_ZERO_UINT32( + "msg.single_uint32", "cel.@attribute(msg, [[3, \"single_uint32\", 13, 0u]])"), + + // Uint64: proto2 has custom default 64, proto3 has 0 + PROTO2_CUSTOM_UINT64( + "proto2_msg.single_uint64", "cel.@attribute(proto2_msg, [[4, \"single_uint64\", 4, 64u]])"), + PROTO3_ZERO_UINT64("msg.single_uint64", "cel.@attribute(msg, [[4, \"single_uint64\", 4, 0u]])"), + + // String: proto2 has custom default "empty", proto3 has "" + PROTO2_CUSTOM_STRING( + "proto2_msg.single_string", + "cel.@attribute(proto2_msg, [[14, \"single_string\", 9, \"empty\"]])"), + PROTO3_ZERO_STRING( + "msg.single_string", "cel.@attribute(msg, [[14, \"single_string\", 9, \"\"]])"), + + // Bool: proto2 has custom default true, proto3 has false + PROTO2_CUSTOM_BOOL( + "proto2_msg.single_bool", "cel.@attribute(proto2_msg, [[13, \"single_bool\", 8, true]])"), + PROTO3_ZERO_BOOL("msg.single_bool", "cel.@attribute(msg, [[13, \"single_bool\", 8, false]])"), + + // Float: proto2 has custom default 3.0, proto3 has 0.0 + PROTO2_CUSTOM_FLOAT( + "proto2_msg.single_float", "cel.@attribute(proto2_msg, [[11, \"single_float\", 2, 3.0]])"), + PROTO3_ZERO_FLOAT("msg.single_float", "cel.@attribute(msg, [[11, \"single_float\", 2, 0.0]])"), + + // Double: proto2 has custom default 6.4, proto3 has 0.0 + PROTO2_CUSTOM_DOUBLE( + "proto2_msg.single_double", + "cel.@attribute(proto2_msg, [[12, \"single_double\", 1, 6.4]])"), + PROTO3_ZERO_DOUBLE( + "msg.single_double", "cel.@attribute(msg, [[12, \"single_double\", 1, 0.0]])"), + + // Bytes: proto2 has custom default "none", proto3 has "" + PROTO2_CUSTOM_BYTES( + "proto2_msg.single_bytes", + "cel.@attribute(proto2_msg, [[15, \"single_bytes\", 12, b\"\\156\\157\\156\\145\"]])"), + PROTO3_ZERO_BYTES( + "msg.single_bytes", "cel.@attribute(msg, [[15, \"single_bytes\", 12, b\"\"]])"), + + // Enum: proto2 has custom default 1 (BAR), proto3 has 0 (FOO) + PROTO2_CUSTOM_ENUM( + "proto2_msg.single_nested_enum", + "cel.@attribute(proto2_msg, [[22, \"single_nested_enum\", 14, 1]])"), + PROTO3_ZERO_ENUM( + "msg.single_nested_enum", "cel.@attribute(msg, [[22, \"single_nested_enum\", 14, 0]])"), + + // Fixed / sfixed fields + PROTO3_SFIXED32( + "msg.single_sfixed32", "cel.@attribute(msg, [[9, \"single_sfixed32\", 15, 0]])"), + PROTO3_SFIXED64( + "msg.single_sfixed64", "cel.@attribute(msg, [[10, \"single_sfixed64\", 16, 0]])"), + + // Repeated fields: empty list default + PROTO2_REPEATED_PRIMITIVE( + "proto2_msg.repeated_int64", + "cel.@attribute(proto2_msg, [[32, \"repeated_int64\", 3, []]])"), + PROTO3_REPEATED_PRIMITIVE( + "msg.repeated_int64", "cel.@attribute(msg, [[32, \"repeated_int64\", 3, []]])"), + PROTO3_REPEATED_MESSAGE( + "msg.repeated_nested_message", + "cel.@attribute(msg, [[51, \"repeated_nested_message\", 11, []]])"), + + // Well-known types + PROTO3_TIMESTAMP( + "msg.single_timestamp", + "cel.@attribute(msg, [[102, \"single_timestamp\", 11," + + " timestamp(\"1970-01-01T00:00:00Z\")]])"), + PROTO3_DURATION( + "msg.single_duration", + "cel.@attribute(msg, [[101, \"single_duration\", 11, duration(\"0s\")]])"), + PROTO3_STRUCT("msg.single_struct", "cel.@attribute(msg, [[103, \"single_struct\", 11, {}]])"), + PROTO3_LIST_VALUE("msg.list_value", "cel.@attribute(msg, [[114, \"list_value\", 11, []]])"), + + // Map selects + MAP_FIELD_INDEXING( + "msg.map_int64_message[1].bb", + "cel.@attribute(" + + "cel.@attribute(msg, [[95, \"map_int64_message\", 11, {}]])[1], " + + "[[1, \"bb\", 5, 0]])"), + + // Mixed expressions + MIXED_BOOLEAN_EXPRESSION( + "msg.single_int64 > 0 && has(msg.single_nested_message)", + "cel.@attribute(msg, [[2, \"single_int64\", 3, 0]]) > 0 " + + "&& cel.@hasField(msg, [[21, \"single_nested_message\"]])"); + + private final String expression; + private final String expectedUnparsed; + + RewriteTestCase(String expression, String expectedUnparsed) { + this.expression = expression; + this.expectedUnparsed = expectedUnparsed; + } + } + + @Test + public void optimize_rewritesSelectExpressions(@TestParameter RewriteTestCase testCase) + throws Exception { + CelAbstractSyntaxTree ast = cel.compile(testCase.expression).getAst(); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + + assertThat(CEL_UNPARSER.unparse(optimizedAst)).isEqualTo(testCase.expectedUnparsed); + assertThat(optimizedAst.getSource().getExtensions()) + .contains(SelectOptimizer.SELECT_OPTIMIZATION_AST_EXTENSION_TAG); + } + + @Test + public void optimize_unoptimizableMapFieldSelect_leavesAstUntouched() throws Exception { + CelAbstractSyntaxTree ast = cel.compile("map_var.key").getAst(); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + + assertThat(CEL_UNPARSER.unparse(optimizedAst)).isEqualTo("map_var.key"); + assertThat(optimizedAst.getSource().getExtensions()) + .doesNotContain(SelectOptimizer.SELECT_OPTIMIZATION_AST_EXTENSION_TAG); + } + + @Test + public void optimize_unoptimizableMapHasField_leavesAstUntouched() throws Exception { + CelAbstractSyntaxTree ast = cel.compile("has(map_var.key)").getAst(); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + + assertThat(CEL_UNPARSER.unparse(optimizedAst)).isEqualTo("has(map_var.key)"); + assertThat(optimizedAst.getSource().getExtensions()) + .doesNotContain(SelectOptimizer.SELECT_OPTIMIZATION_AST_EXTENSION_TAG); + } + + @Test + public void optimize_noSelects_returnsOriginalAst() throws Exception { + CelAbstractSyntaxTree ast = cel.compile("1 + 2 == 3").getAst(); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + + assertThat(optimizedAst).isEqualTo(ast); + assertThat(optimizedAst.getSource().getExtensions()) + .doesNotContain(SelectOptimizer.SELECT_OPTIMIZATION_AST_EXTENSION_TAG); + } + + @Test + public void optimize_notCheckedAst_throwsIllegalArgumentException() throws Exception { + CelAbstractSyntaxTree parsedAst = cel.parse("msg.single_int64").getAst(); + SelectOptimizer optimizer = SelectOptimizer.getInstance(); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> optimizer.optimize(parsedAst, cel)); + + assertThat(exception).hasMessageThat().contains("AST must be type-checked."); + } + + @Test + public void optimize_withFileDescriptors_success() throws Exception { + FileDescriptor fd = TestAllTypes.getDescriptor().getFile(); + SelectOptimizer customOptimizer = + SelectOptimizer.newInstance(SelectOptimizerOptions.newBuilder().build(), fd); + CelOptimizer optimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers(customOptimizer) + .build(); + CelAbstractSyntaxTree ast = cel.compile("msg.single_int64").getAst(); + + CelAbstractSyntaxTree optimizedAst = optimizer.optimize(ast); + + assertThat(CEL_UNPARSER.unparse(optimizedAst)) + .isEqualTo("cel.@attribute(msg, [[2, \"single_int64\", 3, 0]])"); + } + + @Test + public void optimize_withFileDescriptorsIterable_success() throws Exception { + FileDescriptor fd = TestAllTypes.getDescriptor().getFile(); + SelectOptimizer customOptimizer = + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), ImmutableList.of(fd)); + CelOptimizer optimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers(customOptimizer) + .build(); + CelAbstractSyntaxTree ast = cel.compile("msg.single_int64").getAst(); + + CelAbstractSyntaxTree optimizedAst = optimizer.optimize(ast); + + assertThat(CEL_UNPARSER.unparse(optimizedAst)) + .isEqualTo("cel.@attribute(msg, [[2, \"single_int64\", 3, 0]])"); + } + + @Test + public void newInstance_withOptionsAndFileDescriptors_preservesAddedDescriptors() + throws Exception { + FileDescriptor fd = PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFile(); + SelectOptimizerOptions baseOptions = + SelectOptimizerOptions.newBuilder().enableLinkedMessageTypes(false).build(); + SelectOptimizer optimizer = SelectOptimizer.newInstance(baseOptions, fd); + CelAbstractSyntaxTree ast = cel.compile("proto2_msg.single_int64").getAst(); + + CelAbstractSyntaxTree optimizedAst = optimizer.optimize(ast, cel).optimizedAst(); + + assertThat(CEL_UNPARSER.unparse(optimizedAst)) + .isEqualTo("cel.@attribute(proto2_msg, [[2, \"single_int64\", 3, -64]])"); + } + + @Test + public void optimize_defaultOptions_populatesMacroCalls() throws Exception { + CelAbstractSyntaxTree ast = + cel.compile("[1].exists(x, x > 0) && msg.single_int64 > 0").getAst(); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + + assertThat(optimizedAst.getSource().getMacroCalls()).isNotEmpty(); + } + + @Test + public void optimize_populateMacroCallsFalse_clearsMacroCalls() throws Exception { + SelectOptimizer optimizerWithoutMacroCalls = + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().populateMacroCalls(false).build(), + TestAllTypes.getDescriptor().getFile()); + CelOptimizer optimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers(optimizerWithoutMacroCalls) + .build(); + CelAbstractSyntaxTree ast = + cel.compile("[1].exists(x, x > 0) && msg.single_int64 > 0").getAst(); + + CelAbstractSyntaxTree optimizedAst = optimizer.optimize(ast); + + assertThat(optimizedAst.getSource().getMacroCalls()).isEmpty(); + } + + @Test + public void optimize_populateMacroCallsFalse_withoutSelects_clearsMacroCalls() throws Exception { + SelectOptimizer optimizerWithoutMacroCalls = + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().populateMacroCalls(false).build()); + CelOptimizer optimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers(optimizerWithoutMacroCalls) + .build(); + CelAbstractSyntaxTree ast = cel.compile("[1].exists(x, x > 0)").getAst(); + + CelAbstractSyntaxTree optimizedAst = optimizer.optimize(ast); + + assertThat(optimizedAst.getSource().getMacroCalls()).isEmpty(); + } + + @Test + public void optimize_hasFieldMacroCall_removesHasMacroCallFromSource() throws Exception { + CelAbstractSyntaxTree ast = cel.compile("has(msg.single_int64)").getAst(); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + + assertThat(optimizedAst.getSource().getMacroCalls()).isEmpty(); + } + + @Test + public void optimize_renumbersIdsConsecutively() throws Exception { + CelAbstractSyntaxTree ast = cel.compile("msg.single_nested_message.bb").getAst(); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + + CelNavigableMutableAst navAst = + CelNavigableMutableAst.fromAst(CelMutableAst.fromCelAst(optimizedAst)); + ImmutableList ids = + navAst + .getRoot() + .allNodes() + .map(node -> node.expr().id()) + .sorted() + .collect(toImmutableList()); + ImmutableList expectedIds = + LongStream.rangeClosed(1, ids.size()).boxed().collect(toImmutableList()); + assertThat(ids).containsExactlyElementsIn(expectedIds).inOrder(); + } + + @Test + public void optimizeAndEvaluate_withAttributeFunctionBinding_evaluatesSuccessfully() + throws Exception { + Cel celWithBinding = + cel.toCelBuilder() + .addFunctionDeclarations(SelectOptimizer.CEL_ATTRIBUTE_FUNCTION_DECL) + .addFunctionBindings( + CelFunctionBinding.from( + "cel_attribute_list", Object.class, List.class, (target, path) -> 42L)) + .build(); + CelOptimizer optimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(celWithBinding) + .addAstOptimizers( + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile())) + .build(); + CelAbstractSyntaxTree ast = celWithBinding.compile("msg.single_int64").getAst(); + + CelAbstractSyntaxTree optimizedAst = optimizer.optimize(ast); + Object result = + celWithBinding + .createProgram(optimizedAst) + .eval(ImmutableMap.of("msg", TestAllTypes.getDefaultInstance())); + + assertThat(result).isEqualTo(42L); + } + + @Test + public void optimizeAndEvaluate_withHasFieldFunctionBinding_evaluatesSuccessfully() + throws Exception { + Cel celWithBinding = + cel.toCelBuilder() + .addFunctionDeclarations(SelectOptimizer.CEL_HAS_FIELD_FUNCTION_DECL) + .addFunctionBindings( + CelFunctionBinding.from( + "cel_has_field_list", Object.class, List.class, (target, path) -> true)) + .build(); + CelOptimizer optimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(celWithBinding) + .addAstOptimizers( + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile())) + .build(); + CelAbstractSyntaxTree ast = celWithBinding.compile("has(msg.single_int64)").getAst(); + + CelAbstractSyntaxTree optimizedAst = optimizer.optimize(ast); + Object result = + celWithBinding + .createProgram(optimizedAst) + .eval(ImmutableMap.of("msg", TestAllTypes.getDefaultInstance())); + + assertThat((Boolean) result).isTrue(); + } + + @Test + public void optionsBuilder_toBuilderAddFileDescriptors_combinesPools() { + FileDescriptor fd1 = TestAllTypes.getDescriptor().getFile(); + FileDescriptor fd2 = PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFile(); + SelectOptimizerOptions baseOptions = + SelectOptimizerOptions.newBuilder() + .enableLinkedMessageTypes(false) + .addFileDescriptors(fd1) + .build(); + + SelectOptimizerOptions options = baseOptions.toBuilder().addFileDescriptors(fd2).build(); + + assertThat(options.descriptorPool().findDescriptor(TestAllTypes.getDescriptor().getFullName())) + .hasValue(TestAllTypes.getDescriptor()); + assertThat( + options.descriptorPool().findDescriptor(PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFullName())) + .hasValue(PROTO2_TEST_ALL_TYPES_DESCRIPTOR); + } + + @Test + public void optionsBuilder_buildMultipleTimes_isIdempotent() { + FileDescriptor fd = TestAllTypes.getDescriptor().getFile(); + SelectOptimizerOptions.Builder builder = + SelectOptimizerOptions.newBuilder().enableLinkedMessageTypes(false).addFileDescriptors(fd); + + SelectOptimizerOptions options1 = builder.build(); + SelectOptimizerOptions options2 = builder.build(); + + assertThat(options2.descriptorPool()).isSameInstanceAs(options1.descriptorPool()); + } + + @Test + public void optionsBuilder_toBuilderAddFileDescriptorsBuildMultipleTimes_isIdempotent() { + FileDescriptor fd1 = TestAllTypes.getDescriptor().getFile(); + FileDescriptor fd2 = PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFile(); + SelectOptimizerOptions.Builder builder = + SelectOptimizerOptions.newBuilder() + .enableLinkedMessageTypes(false) + .addFileDescriptors(fd1) + .build() + .toBuilder() + .addFileDescriptors(fd2); + + SelectOptimizerOptions options1 = builder.build(); + SelectOptimizerOptions options2 = builder.build(); + + assertThat(options2.descriptorPool()).isSameInstanceAs(options1.descriptorPool()); + } + + @Test + public void optionsBuilder_toBuilderWithoutFileDescriptors_preservesPool() { + FileDescriptor fd = TestAllTypes.getDescriptor().getFile(); + SelectOptimizerOptions baseOptions = + SelectOptimizerOptions.newBuilder() + .enableLinkedMessageTypes(false) + .addFileDescriptors(fd) + .build(); + + SelectOptimizerOptions options = baseOptions.toBuilder().iterationLimit(100).build(); + + assertThat(options.descriptorPool()).isSameInstanceAs(baseOptions.descriptorPool()); + assertThat(options.iterationLimit()).isEqualTo(100); + } + + @Test + public void optionsBuilder_withLinkedDescriptorsDisabled_containsWellKnownTypes() { + SelectOptimizerOptions options = + SelectOptimizerOptions.newBuilder().enableLinkedMessageTypes(false).build(); + + assertThat(options.descriptorPool().findDescriptor("google.protobuf.Timestamp")).isPresent(); + assertThat(options.descriptorPool().findDescriptor("google.protobuf.Duration")).isPresent(); + assertThat(options.descriptorPool().findDescriptor(TestAllTypes.getDescriptor().getFullName())) + .isEmpty(); + } + + @Test + public void optionsBuilder_addFileDescriptorsIterable_withLinkedDescriptorsDisabled_success() { + FileDescriptor fd = PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFile(); + + SelectOptimizerOptions options = + SelectOptimizerOptions.newBuilder() + .enableLinkedMessageTypes(false) + .addFileDescriptors(ImmutableList.of(fd)) + .build(); + + assertThat( + options.descriptorPool().findDescriptor(PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFullName())) + .hasValue(PROTO2_TEST_ALL_TYPES_DESCRIPTOR); + assertThat(options.descriptorPool().findDescriptor("google.protobuf.Timestamp")).isPresent(); + assertThat(options.descriptorPool().findDescriptor("google.protobuf.Duration")).isPresent(); + assertThat(options.descriptorPool().findDescriptor(TestAllTypes.getDescriptor().getFullName())) + .isEmpty(); + } + + @Test + public void optionsBuilder_addFileDescriptorsVarargs_withLinkedDescriptorsDisabled_success() { + FileDescriptor fd = PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFile(); + + SelectOptimizerOptions options = + SelectOptimizerOptions.newBuilder() + .enableLinkedMessageTypes(false) + .addFileDescriptors(fd) + .build(); + + assertThat( + options.descriptorPool().findDescriptor(PROTO2_TEST_ALL_TYPES_DESCRIPTOR.getFullName())) + .hasValue(PROTO2_TEST_ALL_TYPES_DESCRIPTOR); + assertThat(options.descriptorPool().findDescriptor("google.protobuf.Timestamp")).isPresent(); + assertThat(options.descriptorPool().findDescriptor("google.protobuf.Duration")).isPresent(); + assertThat(options.descriptorPool().findDescriptor(TestAllTypes.getDescriptor().getFullName())) + .isEmpty(); + } + + private enum CompilerRejectionTestCase { + ATTRIBUTE_AT_SIGN( + SelectOptimizer.CEL_ATTRIBUTE_FUNCTION_DECL, + "cel.@attribute(msg, [])", + "token recognition error at: '@'"), + ATTRIBUTE_OVERLOAD( + SelectOptimizer.CEL_ATTRIBUTE_FUNCTION_DECL, + "cel_attribute_list(msg, [])", + "undeclared reference to 'cel_attribute_list'"), + HAS_FIELD_AT_SIGN( + SelectOptimizer.CEL_HAS_FIELD_FUNCTION_DECL, + "cel.@hasField(msg, [])", + "token recognition error at: '@'"), + HAS_FIELD_OVERLOAD( + SelectOptimizer.CEL_HAS_FIELD_FUNCTION_DECL, + "cel_has_field_list(msg, [])", + "undeclared reference to 'cel_has_field_list'"); + + private final CelFunctionDecl functionDecl; + private final String expression; + private final String expectedErrorMessage; + + CompilerRejectionTestCase( + CelFunctionDecl functionDecl, String expression, String expectedErrorMessage) { + this.functionDecl = functionDecl; + this.expression = expression; + this.expectedErrorMessage = expectedErrorMessage; + } + } + + @Test + public void compile_sourceWithInternalFunctionCall_failsCompilation( + @TestParameter CompilerRejectionTestCase testCase) { + Cel celWithDecl = cel.toCelBuilder().addFunctionDeclarations(testCase.functionDecl).build(); + + CelValidationException e = + assertThrows( + CelValidationException.class, () -> celWithDecl.compile(testCase.expression).getAst()); + + assertThat(e).hasMessageThat().contains(testCase.expectedErrorMessage); + } + + @Test + public void optimize_toParsedExpr_matchesExpectedSerializedProto() throws Exception { + CelAbstractSyntaxTree ast = cel.compile("msg.single_nested_message.bb").getAst(); + ParsedExpr expectedParsedExpr = + TextFormat.parse( + "expr {\n" + + " id: 1\n" + + " call_expr {\n" + + " function: \"cel.@attribute\"\n" + + " args {\n" + + " id: 2\n" + + " ident_expr {\n" + + " name: \"msg\"\n" + + " }\n" + + " }\n" + + " args {\n" + + " id: 3\n" + + " list_expr {\n" + + " elements {\n" + + " id: 4\n" + + " list_expr {\n" + + " elements {\n" + + " id: 5\n" + + " const_expr {\n" + + " int64_value: 21\n" + + " }\n" + + " }\n" + + " elements {\n" + + " id: 6\n" + + " const_expr {\n" + + " string_value: \"single_nested_message\"\n" + + " }\n" + + " }\n" + + " elements {\n" + + " id: 7\n" + + " const_expr {\n" + + " int64_value: 11\n" + + " }\n" + + " }\n" + + " elements {\n" + + " id: 8\n" + + " const_expr {\n" + + " null_value: NULL_VALUE\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " elements {\n" + + " id: 9\n" + + " list_expr {\n" + + " elements {\n" + + " id: 10\n" + + " const_expr {\n" + + " int64_value: 1\n" + + " }\n" + + " }\n" + + " elements {\n" + + " id: 11\n" + + " const_expr {\n" + + " string_value: \"bb\"\n" + + " }\n" + + " }\n" + + " elements {\n" + + " id: 12\n" + + " const_expr {\n" + + " int64_value: 5\n" + + " }\n" + + " }\n" + + " elements {\n" + + " id: 13\n" + + " const_expr {\n" + + " int64_value: 0\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}\n" + + "source_info {\n" + + " location: \"\"\n" + + " extensions {\n" + + " id: \"select_optimization\"\n" + + " affected_components: COMPONENT_RUNTIME\n" + + " version {\n" + + " major: 1\n" + + " }\n" + + " }\n" + + "}\n", + ParsedExpr.class); + + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + ParsedExpr parsedExpr = CelProtoAbstractSyntaxTree.fromCelAst(optimizedAst).toParsedExpr(); + + assertThat(parsedExpr).isEqualTo(expectedParsedExpr); + } +} diff --git a/policy/src/main/java/dev/cel/policy/tools/BUILD.bazel b/policy/src/main/java/dev/cel/policy/tools/BUILD.bazel new file mode 100644 index 000000000..f124bef3c --- /dev/null +++ b/policy/src/main/java/dev/cel/policy/tools/BUILD.bazel @@ -0,0 +1,49 @@ +load("@rules_java//java:defs.bzl", "java_binary", "java_library") + +package( + default_applicable_licenses = [ + "//:license", + ], + default_visibility = [ + "//policy/src/test/java/dev/cel/policy/tools:__pkg__", + "//policy/tools:__pkg__", + ], +) + +java_library( + name = "tools", + srcs = ["CelPolicyCompilerTool.java"], + deps = [ + "//bundle:cel", + "//bundle:environment", + "//bundle:environment_yaml_parser", + "//common:cel_ast", + "//common:cel_descriptor_util", + "//common:options", + "//common:proto_ast", + "//common:proto_v1alpha1_ast", + "//extensions:optional_library", + "//optimizer/optimizers:common_subexpression_elimination", + "//optimizer/optimizers:constant_folding", + "//optimizer/optimizers:select_optimizer", + "//parser:macro", + "//policy", + "//policy:compiler", + "//policy:compiler_builder", + "//policy:compiler_factory", + "//policy:parser", + "//policy:parser_builder", + "//policy:parser_factory", + "@maven//:com_google_guava_guava", + "@maven//:com_google_protobuf_protobuf_java", + "@maven//:info_picocli_picocli", + "@maven//:org_yaml_snakeyaml", + ], +) + +java_binary( + name = "cel_policy_compiler_tool", + main_class = "dev.cel.policy.tools.CelPolicyCompilerTool", + neverlink = 1, + runtime_deps = [":tools"], +) diff --git a/policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java b/policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java new file mode 100644 index 000000000..baa76ac1c --- /dev/null +++ b/policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java @@ -0,0 +1,299 @@ +// 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.policy.tools; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.base.Ascii; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.protobuf.DescriptorProtos.FileDescriptorSet; +import com.google.protobuf.Descriptors.FileDescriptor; +import com.google.protobuf.ExtensionRegistry; +import com.google.protobuf.Message; +import com.google.protobuf.TextFormat; +import dev.cel.bundle.Cel; +import dev.cel.bundle.CelBuilder; +import dev.cel.bundle.CelEnvironment; +import dev.cel.bundle.CelEnvironmentYamlParser; +import dev.cel.bundle.CelFactory; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelDescriptorUtil; +import dev.cel.common.CelOptions; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.CelProtoV1Alpha1AbstractSyntaxTree; +import dev.cel.extensions.CelOptionalLibrary; +import dev.cel.optimizer.optimizers.ConstantFoldingOptimizer; +import dev.cel.optimizer.optimizers.SelectOptimizer; +import dev.cel.optimizer.optimizers.SelectOptimizer.SelectOptimizerOptions; +import dev.cel.optimizer.optimizers.SubexpressionOptimizer; +import dev.cel.optimizer.optimizers.SubexpressionOptimizer.SubexpressionOptimizerOptions; +import dev.cel.parser.CelStandardMacro; +import dev.cel.policy.CelPolicy; +import dev.cel.policy.CelPolicyCompiler; +import dev.cel.policy.CelPolicyCompilerBuilder; +import dev.cel.policy.CelPolicyCompilerFactory; +import dev.cel.policy.CelPolicyParser; +import dev.cel.policy.CelPolicyParserBuilder; +import dev.cel.policy.CelPolicyParserFactory; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.concurrent.Callable; +import org.yaml.snakeyaml.nodes.Node; +import picocli.CommandLine; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * CelPolicyCompilerTool is a binary tool that compiles a CEL policy (.celpolicy or YAML) into a + * CheckedExpr protobuf message and writes it to a file or stdout. + */ +public final class CelPolicyCompilerTool implements Callable { + + @Option( + names = {"--policy"}, + description = "Path to the CEL policy file") + private String policyPath = ""; + + @Parameters( + index = "0", + arity = "0..1", + description = "Positional path to the CEL policy file if --policy is not specified") + private String positionalPolicyPath = ""; + + @Option( + names = {"--config", "--environment_path"}, + description = "Path to the CEL environment (in YAML)") + private String configPath = ""; + + @Option( + names = {"--base_config"}, + description = "Path to the base CEL environment (in YAML)") + private String baseConfigPath = ""; + + @Option( + names = {"--transitive_descriptor_set", "--file_descriptor_set"}, + description = "Path to the transitive set of descriptors") + private String transitiveDescriptorSetPath = ""; + + @Option( + names = {"--output"}, + description = "Output path for the compiled binarypb/textpb") + private String output = ""; + + @Option( + names = {"--output_format"}, + defaultValue = "binarypb", + description = "Output format: binarypb, textpb, or textproto") + private String outputFormat = "binarypb"; + + @Option( + names = {"--output_version"}, + defaultValue = "canonical", + description = "Output version: canonical or v1alpha1") + private String outputVersion = "canonical"; + + @Option( + names = {"--optimize_field_selection"}, + description = "Optimize field selection for version skew mitigation") + private boolean optimizeFieldSelection = false; + + @Option( + names = {"--simple_variables"}, + description = "Enable simple variables parsing in policy") + private boolean simpleVariables = false; + + private static final CelOptions CEL_OPTIONS = CelOptions.DEFAULT; + + @Override + public Integer call() { + String effectivePolicyPath = policyPath.isEmpty() ? positionalPolicyPath : policyPath; + if (effectivePolicyPath.isEmpty()) { + System.err.println( + "Error: Policy file path must be specified via --policy or as a positional argument."); + return -1; + } + + Cel cel; + ImmutableSet transitiveFileDescriptors; + try { + CelBuilder celBuilder = + CelFactory.standardCelBuilder() + .setOptions(CEL_OPTIONS) + .setStandardMacros(CelStandardMacro.STANDARD_MACROS) + .addCompilerLibraries(CelOptionalLibrary.INSTANCE) + .addRuntimeLibraries(CelOptionalLibrary.INSTANCE); + + if (!transitiveDescriptorSetPath.isEmpty()) { + transitiveFileDescriptors = + CelDescriptorUtil.getFileDescriptorsFromFileDescriptorSet( + load(transitiveDescriptorSetPath)); + celBuilder.addFileTypes(transitiveFileDescriptors); + } else { + transitiveFileDescriptors = ImmutableSet.of(); + } + + cel = celBuilder.build(); + + CelEnvironmentYamlParser environmentYamlParser = CelEnvironmentYamlParser.newInstance(); + if (!baseConfigPath.isEmpty()) { + validateYamlExtension(baseConfigPath, "base CEL environment"); + String baseYaml = new String(readFileBytes(baseConfigPath), UTF_8); + CelEnvironment baseEnv = environmentYamlParser.parse(baseYaml, baseConfigPath); + cel = baseEnv.extend(cel, CEL_OPTIONS); + } + + if (!configPath.isEmpty()) { + validateYamlExtension(configPath, "CEL environment"); + String envYaml = new String(readFileBytes(configPath), UTF_8); + CelEnvironment env = environmentYamlParser.parse(envYaml, configPath); + cel = env.extend(cel, CEL_OPTIONS); + } + } catch (Exception e) { + System.err.printf( + "Failed to create a CEL compilation environment. Reason: %s%n", e.getMessage()); + return -1; + } + + CelPolicy policy; + try { + CelPolicyParserBuilder parserBuilder = CelPolicyParserFactory.newYamlParserBuilder(); + if (simpleVariables) { + parserBuilder.enableSimpleVariables(true); + } + CelPolicyParser policyParser = parserBuilder.build(); + String policyYaml = new String(readFileBytes(effectivePolicyPath), UTF_8); + policy = policyParser.parse(policyYaml, effectivePolicyPath); + } catch (Exception e) { + System.err.printf( + "Failed to parse CEL policy: [%s]. Reason: %s%n", effectivePolicyPath, e.getMessage()); + return -1; + } + + try { + CelPolicyCompilerBuilder policyCompilerBuilder = + CelPolicyCompilerFactory.newPolicyCompiler(cel); + + if (optimizeFieldSelection) { + SelectOptimizerOptions.Builder optionsBuilder = SelectOptimizerOptions.newBuilder(); + if (!transitiveFileDescriptors.isEmpty()) { + optionsBuilder.addFileDescriptors(transitiveFileDescriptors); + } + policyCompilerBuilder.setOptimizers( + ImmutableList.of( + ConstantFoldingOptimizer.getInstance(), + SubexpressionOptimizer.newInstance( + SubexpressionOptimizerOptions.newBuilder().populateMacroCalls(true).build()), + SelectOptimizer.newInstance(optionsBuilder.build()))); + } + + CelPolicyCompiler policyCompiler = policyCompilerBuilder.build(); + CelAbstractSyntaxTree ast = policyCompiler.compile(policy); + + writeOutput(ast, output, outputFormat, outputVersion); + } catch (Exception e) { + System.err.printf( + "%nFailed to compile CEL policy: [%s].%nReason: %s%n%n", + effectivePolicyPath, e.getMessage()); + return -1; + } + + return 0; + } + + private static void writeOutput( + CelAbstractSyntaxTree ast, String filePath, String format, String version) + throws IOException { + Message checkedExpr; + if (Ascii.equalsIgnoreCase("v1alpha1", version)) { + checkedExpr = CelProtoV1Alpha1AbstractSyntaxTree.fromCelAst(ast).toCheckedExpr(); + } else if (Ascii.equalsIgnoreCase("canonical", version)) { + checkedExpr = CelProtoAbstractSyntaxTree.fromCelAst(ast).toCheckedExpr(); + } else { + throw new IllegalArgumentException( + "Unsupported output version: " + version + ". Supported versions: canonical, v1alpha1"); + } + + boolean isText = + Ascii.equalsIgnoreCase("textpb", format) || Ascii.equalsIgnoreCase("textproto", format); + if (!isText && !Ascii.equalsIgnoreCase("binarypb", format)) { + throw new IllegalArgumentException( + "Unsupported output format: " + + format + + ". Supported formats: binarypb, textpb, textproto"); + } + + if (filePath.isEmpty() || filePath.equals("-")) { + if (isText) { + OutputStreamWriter writer = new OutputStreamWriter(System.out, UTF_8); + TextFormat.printer().print(checkedExpr, writer); + writer.flush(); + } else { + checkedExpr.writeTo(System.out); + System.out.flush(); + } + } else { + Path path = Paths.get(filePath); + if (path.getParent() != null) { + Files.createDirectories(path.getParent()); + } + if (isText) { + String text = TextFormat.printer().printToString(checkedExpr); + Files.write(path, text.getBytes(UTF_8)); + } else { + try (FileOutputStream outputStream = new FileOutputStream(path.toFile())) { + checkedExpr.writeTo(outputStream); + } + } + } + } + + private static void validateYamlExtension(String path, String description) { + String lower = path.toLowerCase(Locale.getDefault()).trim(); + if (!lower.endsWith(".yaml") && !lower.endsWith(".yml")) { + throw new IllegalArgumentException( + String.format("Only YAML extension is supported for %s. Got: %s", description, path)); + } + } + + private static FileDescriptorSet load(String descriptorSetPath) { + try { + byte[] descriptorBytes = readFileBytes(descriptorSetPath); + return FileDescriptorSet.parseFrom(descriptorBytes, ExtensionRegistry.getEmptyRegistry()); + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to load FileDescriptorSet from path: " + descriptorSetPath, e); + } + } + + private static byte[] readFileBytes(String path) throws IOException { + return Files.readAllBytes(Paths.get(path)); + } + + public static void main(String[] args) { + CelPolicyCompilerTool compilerTool = new CelPolicyCompilerTool(); + CommandLine cmd = new CommandLine(compilerTool); + cmd.setTrimQuotes(false); + int exitCode = cmd.execute(args); + System.exit(exitCode); + } + + public CelPolicyCompilerTool() {} +} diff --git a/policy/src/test/java/dev/cel/policy/tools/BUILD.bazel b/policy/src/test/java/dev/cel/policy/tools/BUILD.bazel new file mode 100644 index 000000000..28ea5f490 --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/tools/BUILD.bazel @@ -0,0 +1,60 @@ +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_descriptor_set") +load("//:testing.bzl", "junit4_test_suites") +load("//policy/tools:compile_cel_policy.bzl", "compile_cel_policy") + +package( + default_applicable_licenses = ["//:license"], + default_testonly = True, +) + +proto_descriptor_set( + name = "test_all_types_fds", + deps = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], +) + +compile_cel_policy( + name = "macro_compiled_policy", + config = "//testing/environment:proto3_message_variables", + optimize_field_selection = True, + policy = ":test_policy.yaml", + proto_srcs = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], +) + +java_library( + name = "tests", + testonly = True, + srcs = glob(["*Test.java"]), + data = [ + ":macro_compiled_policy.binarypb", + ":test_all_types_fds", + ":test_policy.yaml", + "//testing/environment:proto3_message_variables", + ], + deps = [ + "//:java_truth", + "//common:cel_ast", + "//common:cel_source", + "//common:proto_ast", + "//extensions:optional_library", + "//policy/src/main/java/dev/cel/policy/tools", + "//runtime", + "@bazel_tools//tools/java/runfiles", + "@cel_spec//proto/cel/expr:checked_java_proto", + "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto", + "@com_google_googleapis//google/api/expr/v1alpha1:expr_java_proto", + "@maven//:com_google_guava_guava", + "@maven//:com_google_protobuf_protobuf_java", + "@maven//:info_picocli_picocli", + "@maven//:junit_junit", + ], +) + +junit4_test_suites( + name = "test_suites", + sizes = [ + "small", + ], + src_dir = "src/test/java", + deps = [":tests"], +) diff --git a/policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java b/policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java new file mode 100644 index 000000000..bde95009a --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java @@ -0,0 +1,839 @@ +// 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.policy.tools; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; + +import dev.cel.expr.CheckedExpr; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Files; +import com.google.devtools.build.runfiles.Runfiles; +import com.google.protobuf.ExtensionRegistryLite; +import com.google.protobuf.TextFormat; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.CelSource; +import dev.cel.expr.conformance.proto3.TestAllTypes; +import dev.cel.extensions.CelOptionalLibrary; +import dev.cel.runtime.CelRuntime; +import dev.cel.runtime.CelRuntimeFactory; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.util.Optional; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import picocli.CommandLine; + +@RunWith(JUnit4.class) +public final class CelPolicyCompilerToolTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private Runfiles runfiles; + private CelRuntime celRuntime; + + @Before + public void setUp() throws Exception { + runfiles = Runfiles.preload().unmapped(); + celRuntime = + CelRuntimeFactory.standardCelRuntimeBuilder() + .addLibraries(CelOptionalLibrary.INSTANCE) + .addMessageTypes(TestAllTypes.getDescriptor()) + .build(); + } + + private String resolveRunfile(String rlocationPath) { + String resolved = runfiles.rlocation(rlocationPath); + if (resolved != null && new File(resolved).exists()) { + return resolved; + } + String google3Prefix = "google3/third_party/java/cel/"; + if (rlocationPath.startsWith(google3Prefix)) { + String stripped = rlocationPath.substring(google3Prefix.length()); + String ossPath = runfiles.rlocation("_main/" + stripped); + if (ossPath != null && new File(ossPath).exists()) { + return ossPath; + } + ossPath = runfiles.rlocation(stripped); + if (ossPath != null && new File(ossPath).exists()) { + return ossPath; + } + } + return resolved != null ? resolved : rlocationPath; + } + + @Test + public void compile_basicPolicy_binarypb_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: age\n type: int\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: age-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: age >= 18\n" + + " output: '\"adult\"'\n"); + + File outputFile = tempFolder.newFile("output.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath(), + "--output_format", + "binarypb"); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + + Object result = celRuntime.createProgram(ast).eval(ImmutableMap.of("age", 25L)); + assertThat(result).isEqualTo(Optional.of("adult")); + } + + @Test + public void compile_textpb_format_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: user\n type: string\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: user-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: user == \"alice\"\n" + + " output: 'true'\n"); + + File outputFile = tempFolder.newFile("output.textpb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath(), + "--output_format", + "textpb"); + + assertThat(exitCode).isEqualTo(0); + + String content = Files.asCharSource(outputFile, UTF_8).read(); + assertThat(content).contains("call_expr"); + + CheckedExpr checkedExpr = TextFormat.parse(content, CheckedExpr.class); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + Object result = celRuntime.createProgram(ast).eval(ImmutableMap.of("user", "alice")); + assertThat(result).isEqualTo(Optional.of(true)); + } + + @Test + public void compile_outputVersion_v1alpha1_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: x\n type: int\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write("name: p\nrule:\n match:\n - condition: x > 0\n output: 'true'\n"); + + File outputFile = tempFolder.newFile("output.v1alpha1.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath(), + "--output_version", + "v1alpha1", + "--output_format", + "binarypb"); + + assertThat(exitCode).isEqualTo(0); + + com.google.api.expr.v1alpha1.CheckedExpr v1alpha1Expr = + com.google.api.expr.v1alpha1.CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + assertThat(v1alpha1Expr.hasExpr()).isTrue(); + } + + @Test + public void compile_withBaseConfig_success() throws Exception { + File baseConfigFile = tempFolder.newFile("base_config.yaml"); + Files.asCharSink(baseConfigFile, UTF_8) + .write("name: base-env\nvariables:\n - name: base_var\n type: string\n"); + + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: sub-env\nvariables:\n - name: sub_var\n type: string\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: p\n" + + "rule:\n" + + " match:\n" + + " - condition: base_var == \"hello\" && sub_var == \"world\"\n" + + " output: 'true'\n"); + + File outputFile = tempFolder.newFile("output.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--base_config", + baseConfigFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + Object result = + celRuntime + .createProgram(ast) + .eval(ImmutableMap.of("base_var", "hello", "sub_var", "world")); + assertThat(result).isEqualTo(Optional.of(true)); + } + + @Test + public void compile_withSimpleVariables_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: p\n" + + "rule:\n" + + " variables:\n" + + " - my_sum: 10 + 20\n" + + " match:\n" + + " - condition: variables.my_sum == 30\n" + + " output: 'true'\n"); + + File outputFile = tempFolder.newFile("output.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath(), + "--simple_variables"); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + Object result = celRuntime.createProgram(ast).eval(); + assertThat(result).isEqualTo(Optional.of(true)); + } + + @Test + public void compile_withOptimizeFieldSelection_rewritesSelectAndAddsExtension() throws Exception { + String configRlocation = + "google3/third_party/java/cel/testing/src/test/resources/environment/proto3_message_variables.yaml"; + String fdsRlocation = + "google3/third_party/java/cel/policy/src/test/java/dev/cel/policy/tools/test_all_types_fds.pb"; + + String configPath = resolveRunfile(configRlocation); + String fdsPath = resolveRunfile(fdsRlocation); + + File policyFile = tempFolder.newFile("proto_policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: proto-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: proto3.single_int32 == 1\n" + + " output: '\"OK\"'\n"); + + File outputFile = tempFolder.newFile("output_optimized.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configPath, + "--transitive_descriptor_set", + fdsPath, + "--output", + outputFile.getAbsolutePath(), + "--optimize_field_selection"); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + + // Verify Extension tag "select_optimization" is attached to source info + assertThat(ast.getSource().getExtensions()) + .contains( + CelSource.Extension.create( + "select_optimization", + CelSource.Extension.Version.of(1L, 0L), + CelSource.Extension.Component.COMPONENT_RUNTIME)); + + // Verify AST was rewritten to call cel.@attribute + String unparsedText = checkedExpr.toString(); + assertThat(unparsedText).contains("cel.@attribute"); + } + + @Test + public void compile_presenceTest_withOptimizeFieldSelection_rewritesHasField() throws Exception { + String configRlocation = + "google3/third_party/java/cel/testing/src/test/resources/environment/proto3_message_variables.yaml"; + String fdsRlocation = + "google3/third_party/java/cel/policy/src/test/java/dev/cel/policy/tools/test_all_types_fds.pb"; + + String configPath = resolveRunfile(configRlocation); + String fdsPath = resolveRunfile(fdsRlocation); + + File policyFile = tempFolder.newFile("presence_policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: presence-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: has(proto3.single_int32)\n" + + " output: '\"EXISTS\"'\n"); + + File outputFile = tempFolder.newFile("output_presence.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configPath, + "--transitive_descriptor_set", + fdsPath, + "--output", + outputFile.getAbsolutePath(), + "--optimize_field_selection"); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + + assertThat(ast.getSource().getExtensions()) + .contains( + CelSource.Extension.create( + "select_optimization", + CelSource.Extension.Version.of(1L, 0L), + CelSource.Extension.Component.COMPONENT_RUNTIME)); + + assertThat(checkedExpr.toString()).contains("cel.@hasField"); + } + + @Test + public void compile_macroTarget_verified() throws Exception { + String macroArtifactRlocation = + "google3/third_party/java/cel/policy/src/test/java/dev/cel/policy/tools/macro_compiled_policy.binarypb"; + File compiledFile = new File(resolveRunfile(macroArtifactRlocation)); + assertThat(compiledFile.exists()).isTrue(); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(compiledFile), ExtensionRegistryLite.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + + assertThat(ast.getSource().getExtensions()) + .contains( + CelSource.Extension.create( + "select_optimization", + CelSource.Extension.Version.of(1L, 0L), + CelSource.Extension.Component.COMPONENT_RUNTIME)); + + assertThat(checkedExpr.toString()).contains("cel.@attribute"); + } + + @Test + public void compile_error_missingPolicy_returnsErrorCode() { + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = cmd.execute("--config", "foo.yaml", "--output", "out.binarypb"); + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_invalidPolicyYaml_returnsErrorCode() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + File policyFile = tempFolder.newFile("bad_policy.yaml"); + Files.asCharSink(policyFile, UTF_8).write("not a valid yaml: [unclosed list\n"); + + File outputFile = tempFolder.newFile("output.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_stdout_textpb_format_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: user\n type: string\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: user-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: user == \"alice\"\n" + + " output: 'true'\n"); + + PrintStream originalOut = System.out; + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(outContent, true, UTF_8.name())); + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output_format", + "textpb"); + assertThat(exitCode).isEqualTo(0); + } finally { + System.setOut(originalOut); + } + + String outputText = new String(outContent.toByteArray(), UTF_8); + assertThat(outputText).contains("call_expr"); + } + + @Test + public void compile_stdout_textproto_withDashOutput_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: user\n type: string\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: user-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: user == \"alice\"\n" + + " output: 'true'\n"); + + PrintStream originalOut = System.out; + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(outContent, true, UTF_8.name())); + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + "-", + "--output_format", + "textproto"); + assertThat(exitCode).isEqualTo(0); + } finally { + System.setOut(originalOut); + } + + String outputText = new String(outContent.toByteArray(), UTF_8); + assertThat(outputText).contains("call_expr"); + } + + @Test + public void compile_stdout_binarypb_withDashOutput_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: age\n type: int\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: age-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: age >= 18\n" + + " output: '\"adult\"'\n"); + + PrintStream originalOut = System.out; + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(outContent, true, UTF_8.name())); + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + "-", + "--output_format", + "binarypb"); + assertThat(exitCode).isEqualTo(0); + } finally { + System.setOut(originalOut); + } + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom(outContent.toByteArray(), ExtensionRegistryLite.getEmptyRegistry()); + assertThat(checkedExpr.hasExpr()).isTrue(); + } + + @Test + public void compile_stdout_defaultOmittedOutput_binarypb_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: age\n type: int\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: age-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: age >= 18\n" + + " output: '\"adult\"'\n"); + + PrintStream originalOut = System.out; + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(outContent, true, UTF_8.name())); + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", policyFile.getAbsolutePath(), "--config", configFile.getAbsolutePath()); + assertThat(exitCode).isEqualTo(0); + } finally { + System.setOut(originalOut); + } + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom(outContent.toByteArray(), ExtensionRegistryLite.getEmptyRegistry()); + assertThat(checkedExpr.hasExpr()).isTrue(); + } + + @Test + public void compile_withPositionalPolicyPath_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: age\n type: int\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: age-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: age >= 18\n" + + " output: '\"adult\"'\n"); + + File outputFile = tempFolder.newFile("output.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistryLite.getEmptyRegistry()); + assertThat(checkedExpr.hasExpr()).isTrue(); + } + + @Test + public void compile_withNestedOutputDirectory_createsDirectories_success() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: age\n type: int\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: age-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: age >= 18\n" + + " output: '\"adult\"'\n"); + + File nestedOutputFile = new File(tempFolder.getRoot(), "sub/nested/dir/output.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + nestedOutputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + assertThat(nestedOutputFile.exists()).isTrue(); + } + + @Test + public void compile_withYmlConfigExtension_success() throws Exception { + File configFile = tempFolder.newFile("config.yml"); + Files.asCharSink(configFile, UTF_8) + .write("name: test-env\nvariables:\n - name: age\n type: int\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: age-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: age >= 18\n" + + " output: '\"adult\"'\n"); + + File outputFile = tempFolder.newFile("output.binarypb"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + } + + @Test + public void compile_error_unsupportedOutputVersion_returnsErrorCode() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write("name: p\nrule:\n match:\n - condition: 'true'\n output: 'true'\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output_version", + "unsupported_version"); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_unsupportedOutputFormat_returnsErrorCode() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write("name: p\nrule:\n match:\n - condition: 'true'\n output: 'true'\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--output_format", + "json"); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_invalidConfigExtension_returnsErrorCode() throws Exception { + File configFile = tempFolder.newFile("config.json"); + Files.asCharSink(configFile, UTF_8).write("{\"name\": \"test-env\"}"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write("name: p\nrule:\n match:\n - condition: true\n output: 'true'\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", policyFile.getAbsolutePath(), "--config", configFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_invalidBaseConfigExtension_returnsErrorCode() throws Exception { + File baseConfigFile = tempFolder.newFile("base_config.json"); + Files.asCharSink(baseConfigFile, UTF_8).write("{\"name\": \"base-env\"}"); + + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write("name: p\nrule:\n match:\n - condition: true\n output: 'true'\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--base_config", + baseConfigFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_policyCompilationFailure_returnsErrorCode() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + File policyFile = tempFolder.newFile("undeclared_policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write( + "name: undeclared-policy\n" + + "rule:\n" + + " match:\n" + + " - condition: undeclared_identifier == 42\n" + + " output: 'true'\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", policyFile.getAbsolutePath(), "--config", configFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_nonExistentPolicyFile_returnsErrorCode() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", "non_existent_policy.yaml", "--config", configFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_nonExistentConfigFile_returnsErrorCode() throws Exception { + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write("name: p\nrule:\n match:\n - condition: true\n output: 'true'\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", policyFile.getAbsolutePath(), "--config", "non_existent_config.yaml"); + + assertThat(exitCode).isEqualTo(-1); + } + + @Test + public void compile_error_nonExistentDescriptorSet_returnsErrorCode() throws Exception { + File configFile = tempFolder.newFile("config.yaml"); + Files.asCharSink(configFile, UTF_8).write("name: test-env\n"); + + File policyFile = tempFolder.newFile("policy.yaml"); + Files.asCharSink(policyFile, UTF_8) + .write("name: p\nrule:\n match:\n - condition: true\n output: 'true'\n"); + + CommandLine cmd = new CommandLine(new CelPolicyCompilerTool()); + int exitCode = + cmd.execute( + "--policy", + policyFile.getAbsolutePath(), + "--config", + configFile.getAbsolutePath(), + "--transitive_descriptor_set", + "non_existent_descriptors.pb"); + + assertThat(exitCode).isEqualTo(-1); + } +} diff --git a/policy/src/test/java/dev/cel/policy/tools/test_policy.yaml b/policy/src/test/java/dev/cel/policy/tools/test_policy.yaml new file mode 100644 index 000000000..bef0be2a7 --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/tools/test_policy.yaml @@ -0,0 +1,6 @@ +# Copyright 2026 Google LLC +name: "test_policy" +rule: + match: + - condition: proto3.single_int32 == 1 + output: '"MATCHED"' diff --git a/policy/tools/BUILD.bazel b/policy/tools/BUILD.bazel new file mode 100644 index 000000000..35ea17203 --- /dev/null +++ b/policy/tools/BUILD.bazel @@ -0,0 +1,11 @@ +package( + default_applicable_licenses = ["//:license"], + default_visibility = ["//visibility:public"], +) + +exports_files(["compile_cel_policy.bzl"]) + +alias( + name = "cel_policy_compiler_tool", + actual = "//policy/src/main/java/dev/cel/policy/tools:cel_policy_compiler_tool", +) diff --git a/policy/tools/compile_cel_policy.bzl b/policy/tools/compile_cel_policy.bzl new file mode 100644 index 000000000..a0a9cc4f2 --- /dev/null +++ b/policy/tools/compile_cel_policy.bzl @@ -0,0 +1,107 @@ +# 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. +"""Rule for compiling CEL policies at build time.""" + +load("@rules_proto//proto:defs.bzl", "proto_descriptor_set") + +def compile_cel_policy( + name, + policy, + config, + base_config = None, + proto_srcs = [], + file_descriptor_set = None, + output = None, + output_format = "binarypb", + output_version = "canonical", + optimize_field_selection = False, + simple_variables = False, + visibility = None): + """Compiles a CEL policy into a CheckedExpr binarypb or textpb with optional select optimization. + + This macro wraps an invocation of cel_policy_compiler_tool with a genrule. The rule output + will be a CheckedExpr message in the requested version (canonical or v1alpha1) and format + (binarypb, textpb, or textproto). + + Args: + name: str name for the generated artifact + policy: label of a file describing a CEL policy (.celpolicy or .yaml) + config: label of a file describing the CEL policy environment in YAML + base_config: (optional) label of a file describing the base environment configuration in YAML + proto_srcs: (optional) list of str label(s) pointing to proto_library rule(s) + file_descriptor_set: (optional) str label or filename pointing to a FileDescriptorSet message + output: (optional) str file name for the output checked expression (default derived from label name and format) + output_format: (optional) str either "binarypb", "textpb", or "textproto" (default "binarypb") + output_version: (optional) str either "canonical" or "v1alpha1" (default "canonical") + optimize_field_selection: (optional) bool whether to enable AST select optimization (default False) + simple_variables: (optional) bool whether to enable simple variables parsing (default False) + visibility: (optional) visibility to use on the genrule macro (default None) + """ + if output_format not in ("binarypb", "textpb", "textproto"): + fail("output_format only supports 'binarypb', 'textpb', and 'textproto'") + + if output_version not in ("canonical", "v1alpha1"): + fail("output_version only supports 'canonical' and 'v1alpha1'") + + if output == None: + output = name + "." + output_format + + args = [] + srcs = [policy, config] + + args.append("--policy=$(location %s)" % policy) + args.append("--config=$(location %s)" % config) + + if base_config != None: + args.append("--base_config=$(location %s)" % base_config) + srcs.append(base_config) + + if len(proto_srcs) > 0 and file_descriptor_set != None: + fail("Cannot specify both proto_srcs and file_descriptor_set in compile_cel_policy") + + if len(proto_srcs) > 0: + transitive_descriptor_set_name = "%s_transitive_descriptor_set" % name + proto_descriptor_set( + name = transitive_descriptor_set_name, + deps = proto_srcs, + ) + file_descriptor_set = transitive_descriptor_set_name + + if file_descriptor_set != None: + args.append("--file_descriptor_set=$(location %s)" % file_descriptor_set) + srcs.append(file_descriptor_set) + + args.append("--output=$(location %s)" % output) + args.append("--output_format=" + output_format) + args.append("--output_version=" + output_version) + + if optimize_field_selection: + args.append("--optimize_field_selection") + + if simple_variables: + args.append("--simple_variables") + + cmd = ( + "$(location //policy/tools:cel_policy_compiler_tool) " + + " ".join(args) + ) + + native.genrule( + name = name, + cmd = cmd, + srcs = srcs, + outs = [output], + tools = ["//policy/tools:cel_policy_compiler_tool"], + visibility = visibility, + ) diff --git a/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java b/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java index c066bb18e..d5e8babed 100644 --- a/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java +++ b/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java @@ -18,6 +18,7 @@ import com.google.errorprone.annotations.Immutable; import com.google.protobuf.MessageLite; +import com.google.protobuf.WireFormat; import dev.cel.common.annotations.Internal; import java.util.Collections; import java.util.HashMap; @@ -84,6 +85,10 @@ public Optional findByFieldNumber(int fieldNumber) { return Optional.ofNullable(fieldNumberToFieldDescriptors.get(fieldNumber)); } + public Optional findByFieldName(String fieldName) { + return Optional.ofNullable(fieldNameToFieldDescriptors.get(fieldName)); + } + public FieldLiteDescriptor getByFieldNameOrThrow(String fieldName) { return Objects.requireNonNull(fieldNameToFieldDescriptors.get(fieldName)); } @@ -184,24 +189,65 @@ public enum JavaType { *

This is exactly the same as com.google.protobuf.Descriptors#Type */ public enum Type { - DOUBLE, - FLOAT, - INT64, - UINT64, - INT32, - FIXED64, - FIXED32, - BOOL, - STRING, - GROUP, - MESSAGE, - BYTES, - UINT32, - ENUM, - SFIXED32, - SFIXED64, - SINT32, - SINT64 + DOUBLE(1, WireFormat.FieldType.DOUBLE), + FLOAT(2, WireFormat.FieldType.FLOAT), + INT64(3, WireFormat.FieldType.INT64), + UINT64(4, WireFormat.FieldType.UINT64), + INT32(5, WireFormat.FieldType.INT32), + FIXED64(6, WireFormat.FieldType.FIXED64), + FIXED32(7, WireFormat.FieldType.FIXED32), + BOOL(8, WireFormat.FieldType.BOOL), + STRING(9, WireFormat.FieldType.STRING), + GROUP(10, WireFormat.FieldType.GROUP), + MESSAGE(11, WireFormat.FieldType.MESSAGE), + BYTES(12, WireFormat.FieldType.BYTES), + UINT32(13, WireFormat.FieldType.UINT32), + ENUM(14, WireFormat.FieldType.ENUM), + SFIXED32(15, WireFormat.FieldType.SFIXED32), + SFIXED64(16, WireFormat.FieldType.SFIXED64), + SINT32(17, WireFormat.FieldType.SINT32), + SINT64(18, WireFormat.FieldType.SINT64); + + private final int number; + private final WireFormat.FieldType wireFormatFieldType; + + Type(int number, WireFormat.FieldType wireFormatFieldType) { + this.number = number; + this.wireFormatFieldType = wireFormatFieldType; + } + + public int getNumber() { + return number; + } + + public WireFormat.FieldType toWireFormatFieldType() { + return wireFormatFieldType; + } + + public boolean isPackable() { + return wireFormatFieldType.isPackable(); + } + + private static final Type[] typesByNumber; + + static { + Type[] values = values(); + typesByNumber = new Type[values.length + 1]; + for (Type type : values) { + typesByNumber[type.number] = type; + } + } + + public static Type forNumber(int number) { + if (number < 1 || number >= typesByNumber.length) { + throw new IllegalArgumentException("Unsupported proto type code: " + number); + } + return typesByNumber[number]; + } + } + + public int getFieldNumber() { + return fieldNumber; } public String getFieldName() { diff --git a/protobuf/src/test/java/dev/cel/protobuf/BUILD.bazel b/protobuf/src/test/java/dev/cel/protobuf/BUILD.bazel index 58e298b29..635379aab 100644 --- a/protobuf/src/test/java/dev/cel/protobuf/BUILD.bazel +++ b/protobuf/src/test/java/dev/cel/protobuf/BUILD.bazel @@ -16,6 +16,7 @@ java_test( "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto_lite", "@maven//:com_google_testparameterinjector_test_parameter_injector", "@maven//:junit_junit", + "@maven_android//:com_google_protobuf_protobuf_javalite", ], ) diff --git a/protobuf/src/test/java/dev/cel/protobuf/CelLiteDescriptorTest.java b/protobuf/src/test/java/dev/cel/protobuf/CelLiteDescriptorTest.java index 1ceed29bb..a0878a62f 100644 --- a/protobuf/src/test/java/dev/cel/protobuf/CelLiteDescriptorTest.java +++ b/protobuf/src/test/java/dev/cel/protobuf/CelLiteDescriptorTest.java @@ -15,7 +15,10 @@ package dev.cel.protobuf; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import com.google.protobuf.WireFormat; +import com.google.testing.junit.testparameterinjector.TestParameter; import com.google.testing.junit.testparameterinjector.TestParameterInjector; import dev.cel.expr.conformance.proto3.TestAllTypesCelLiteDescriptor; import dev.cel.protobuf.CelLiteDescriptor.FieldLiteDescriptor; @@ -146,4 +149,99 @@ public void fieldDescriptor_nestedMessage_fullyQualifiedNames() { assertThat(fieldLiteDescriptor.getFieldProtoTypeName()) .isEqualTo("cel.expr.conformance.proto3.TestAllTypes.NestedMessage"); } + + @Test + public void protoFieldType_numbersAndWireTypes() { + assertThat(FieldLiteDescriptor.Type.DOUBLE.getNumber()).isEqualTo(1); + assertThat(FieldLiteDescriptor.Type.DOUBLE.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.DOUBLE); + + assertThat(FieldLiteDescriptor.Type.FLOAT.getNumber()).isEqualTo(2); + assertThat(FieldLiteDescriptor.Type.FLOAT.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.FLOAT); + + assertThat(FieldLiteDescriptor.Type.INT64.getNumber()).isEqualTo(3); + assertThat(FieldLiteDescriptor.Type.INT64.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.INT64); + + assertThat(FieldLiteDescriptor.Type.UINT64.getNumber()).isEqualTo(4); + assertThat(FieldLiteDescriptor.Type.UINT64.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.UINT64); + + assertThat(FieldLiteDescriptor.Type.INT32.getNumber()).isEqualTo(5); + assertThat(FieldLiteDescriptor.Type.INT32.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.INT32); + + assertThat(FieldLiteDescriptor.Type.FIXED64.getNumber()).isEqualTo(6); + assertThat(FieldLiteDescriptor.Type.FIXED64.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.FIXED64); + + assertThat(FieldLiteDescriptor.Type.FIXED32.getNumber()).isEqualTo(7); + assertThat(FieldLiteDescriptor.Type.FIXED32.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.FIXED32); + + assertThat(FieldLiteDescriptor.Type.BOOL.getNumber()).isEqualTo(8); + assertThat(FieldLiteDescriptor.Type.BOOL.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.BOOL); + + assertThat(FieldLiteDescriptor.Type.STRING.getNumber()).isEqualTo(9); + assertThat(FieldLiteDescriptor.Type.STRING.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.STRING); + + assertThat(FieldLiteDescriptor.Type.GROUP.getNumber()).isEqualTo(10); + assertThat(FieldLiteDescriptor.Type.GROUP.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.GROUP); + + assertThat(FieldLiteDescriptor.Type.MESSAGE.getNumber()).isEqualTo(11); + assertThat(FieldLiteDescriptor.Type.MESSAGE.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.MESSAGE); + + assertThat(FieldLiteDescriptor.Type.BYTES.getNumber()).isEqualTo(12); + assertThat(FieldLiteDescriptor.Type.BYTES.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.BYTES); + + assertThat(FieldLiteDescriptor.Type.UINT32.getNumber()).isEqualTo(13); + assertThat(FieldLiteDescriptor.Type.UINT32.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.UINT32); + + assertThat(FieldLiteDescriptor.Type.ENUM.getNumber()).isEqualTo(14); + assertThat(FieldLiteDescriptor.Type.ENUM.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.ENUM); + + assertThat(FieldLiteDescriptor.Type.SFIXED32.getNumber()).isEqualTo(15); + assertThat(FieldLiteDescriptor.Type.SFIXED32.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.SFIXED32); + + assertThat(FieldLiteDescriptor.Type.SFIXED64.getNumber()).isEqualTo(16); + assertThat(FieldLiteDescriptor.Type.SFIXED64.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.SFIXED64); + + assertThat(FieldLiteDescriptor.Type.SINT32.getNumber()).isEqualTo(17); + assertThat(FieldLiteDescriptor.Type.SINT32.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.SINT32); + + assertThat(FieldLiteDescriptor.Type.SINT64.getNumber()).isEqualTo(18); + assertThat(FieldLiteDescriptor.Type.SINT64.toWireFormatFieldType()) + .isEqualTo(WireFormat.FieldType.SINT64); + } + + @Test + public void protoFieldType_forNumber_roundTripAllTypes( + @TestParameter FieldLiteDescriptor.Type type) { + assertThat(FieldLiteDescriptor.Type.forNumber(type.getNumber())).isEqualTo(type); + } + + @Test + public void protoFieldType_forNumber_outOfRange_throws() { + assertThrows(IllegalArgumentException.class, () -> FieldLiteDescriptor.Type.forNumber(0)); + assertThrows(IllegalArgumentException.class, () -> FieldLiteDescriptor.Type.forNumber(19)); + } + + @Test + public void protoFieldType_isPackable() { + assertThat(FieldLiteDescriptor.Type.INT32.isPackable()).isTrue(); + assertThat(FieldLiteDescriptor.Type.STRING.isPackable()).isFalse(); + assertThat(FieldLiteDescriptor.Type.MESSAGE.isPackable()).isFalse(); + assertThat(FieldLiteDescriptor.Type.BYTES.isPackable()).isFalse(); + } } diff --git a/runtime/planner/BUILD.bazel b/runtime/planner/BUILD.bazel index 860d413a0..4f30eaa69 100644 --- a/runtime/planner/BUILD.bazel +++ b/runtime/planner/BUILD.bazel @@ -21,3 +21,13 @@ java_library( visibility = ["//:internal"], exports = ["//runtime/src/main/java/dev/cel/runtime/planner:planned_program"], ) + +java_library( + name = "attribute", + exports = ["//runtime/src/main/java/dev/cel/runtime/planner:attribute"], +) + +cel_android_library( + name = "attribute_android", + exports = ["//runtime/src/main/java/dev/cel/runtime/planner:attribute_android"], +) diff --git a/runtime/src/main/java/dev/cel/runtime/BUILD.bazel b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel index 489bb64d8..4eaad22a4 100644 --- a/runtime/src/main/java/dev/cel/runtime/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/BUILD.bazel @@ -41,6 +41,7 @@ LITE_RUNTIME_SOURCES = [ # keep sorted LITE_RUNTIME_IMPL_SOURCES = [ + "LiteAttributeStep.java", "LiteRuntimeImpl.java", ] @@ -985,10 +986,14 @@ java_library( "//common:cel_ast", "//common:container", "//common:options", + "//common/annotations", + "//common/exceptions:attribute_not_found", "//common/types:default_type_provider", "//common/types:type_providers", "//common/values", "//common/values:cel_value_provider", + "//common/values:proto_message_lite_value", + "//protobuf:cel_lite_descriptor", "//runtime:evaluation_exception", "//runtime/planner:program_planner", "//runtime/standard:standard_function", @@ -1013,10 +1018,14 @@ cel_android_library( "//common:cel_ast_android", "//common:container_android", "//common:options", + "//common/annotations", + "//common/exceptions:attribute_not_found", "//common/types:default_type_provider_android", "//common/types:type_providers_android", "//common/values:cel_value_provider_android", + "//common/values:proto_message_lite_value_android", "//common/values:values_android", + "//protobuf:cel_lite_descriptor", "//runtime:evaluation_exception", "//runtime/planner:program_planner_android", "//runtime/standard:standard_function_android", diff --git a/runtime/src/main/java/dev/cel/runtime/LiteAttributeStep.java b/runtime/src/main/java/dev/cel/runtime/LiteAttributeStep.java new file mode 100644 index 000000000..cc0a4a6c2 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/LiteAttributeStep.java @@ -0,0 +1,391 @@ +// 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.runtime; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.Immutable; +import dev.cel.common.annotations.Internal; +import dev.cel.common.exceptions.CelAttributeNotFoundException; +import dev.cel.common.values.CelValueConverter; +import dev.cel.common.values.NullValue; +import dev.cel.common.values.OptionalValue; +import dev.cel.common.values.ProtoMessageLiteValue; +import dev.cel.common.values.RawProtoMessageLiteValue; +import dev.cel.common.values.SelectableValue; +import dev.cel.protobuf.CelLiteDescriptor.FieldLiteDescriptor; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * LiteAttributeStep provides qualification steps for evaluating optimized {@code cel.@attribute} + * and {@code cel.@hasField} expressions on Protobuf Lite messages. + * + *

CEL Library Internals. Do Not Use. + */ +@Internal +public final class LiteAttributeStep { + + @Immutable + private interface Step { + Object qualify(Object value); + } + + /** Step representing a single selection step in a {@code cel.@attribute} chain. */ + @Immutable + private static final class LiteSelectQualifier implements Step { + private final int fieldNumber; + private final String fieldName; + private final int typeCode; + + @SuppressWarnings("Immutable") + private final Object defaultValue; + + @Override + public Object qualify(Object obj) { + if (obj == null || obj instanceof NullValue) { + return defaultValue; + } + + if (obj instanceof OptionalValue) { + OptionalValue opt = (OptionalValue) obj; + if (opt.isZeroValue()) { + return OptionalValue.EMPTY; + } + obj = opt.value(); + if (obj == null || obj instanceof NullValue) { + return defaultValue; + } + } + + if (obj instanceof ProtoMessageLiteValue) { + ProtoMessageLiteValue msg = (ProtoMessageLiteValue) obj; + Object fieldValue = msg.fieldValues().get(fieldName); + if (fieldValue != null) { + return msg.protoLiteCelValueConverter().toRuntimeValue(fieldValue); + } + + ImmutableList unknowns = msg.unknownFields().get(fieldNumber); + if (!unknowns.isEmpty()) { + boolean isRepeated = defaultValue instanceof List; + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + unknowns, typeCode, /* protoTypeName= */ fieldName, isRepeated); + if (decoded != null) { + return msg.protoLiteCelValueConverter().toRuntimeValue(decoded); + } + } + + Optional descDefault = + msg.protoLiteCelValueConverter().findDefaultCelValue(msg.celType().name(), fieldName); + if (descDefault.isPresent()) { + return descDefault.get(); + } + + return defaultValue; + } + + if (obj instanceof RawProtoMessageLiteValue) { + RawProtoMessageLiteValue rawMsg = (RawProtoMessageLiteValue) obj; + ImmutableList unknowns = rawMsg.unknownFields().get(fieldNumber); + if (!unknowns.isEmpty()) { + boolean isRepeated = defaultValue instanceof List; + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + unknowns, typeCode, /* protoTypeName= */ fieldName, isRepeated); + if (decoded != null) { + return decoded; + } + } + + return defaultValue; + } + + if (obj instanceof SelectableValue) { + @SuppressWarnings("unchecked") // Safe cast: SelectableValue keys on String + SelectableValue selectable = (SelectableValue) obj; + Optional found = selectable.find(fieldName); + if (found.isPresent()) { + return found.get(); + } + return defaultValue; + } + + if (obj instanceof Map) { + Map map = (Map) obj; + Object mapVal = map.get(fieldName); + if (mapVal != null) { + return mapVal; + } + if (map.containsKey(fieldName)) { + return NullValue.NULL_VALUE; + } + throw CelAttributeNotFoundException.forMissingMapKey(fieldName); + } + + throw CelAttributeNotFoundException.forFieldResolution(fieldName); + } + + private static LiteSelectQualifier create( + int fieldNumber, String fieldName, int typeCode, Object defaultValue) { + return new LiteSelectQualifier( + fieldNumber, + fieldName, + typeCode, + defaultValue == null ? NullValue.NULL_VALUE : defaultValue); + } + + private LiteSelectQualifier( + int fieldNumber, String fieldName, int typeCode, Object defaultValue) { + this.fieldNumber = fieldNumber; + this.fieldName = checkNotNull(fieldName); + this.typeCode = typeCode; + this.defaultValue = defaultValue; + } + } + + /** Step representing an intermediate submessage navigation step in {@code cel.@hasField}. */ + @Immutable + private static final class LiteSubmessageQualifier implements Step { + private final int fieldNumber; + private final String fieldName; + + @Override + public Object qualify(Object obj) { + if (obj == null || obj instanceof NullValue) { + return NullValue.NULL_VALUE; + } + + if (obj instanceof OptionalValue) { + OptionalValue opt = (OptionalValue) obj; + if (opt.isZeroValue()) { + return NullValue.NULL_VALUE; + } + obj = opt.value(); + if (obj == null || obj instanceof NullValue) { + return NullValue.NULL_VALUE; + } + } + + if (obj instanceof ProtoMessageLiteValue) { + ProtoMessageLiteValue msg = (ProtoMessageLiteValue) obj; + Object fieldValue = msg.fieldValues().get(fieldName); + if (fieldValue != null) { + return msg.protoLiteCelValueConverter().toRuntimeValue(fieldValue); + } + + ImmutableList unknowns = msg.unknownFields().get(fieldNumber); + if (!unknowns.isEmpty()) { + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + unknowns, + FieldLiteDescriptor.Type.MESSAGE.getNumber(), + /* protoTypeName= */ fieldName, + /* isRepeated= */ false); + if (decoded != null) { + return msg.protoLiteCelValueConverter().toRuntimeValue(decoded); + } + } + + Optional descDefault = + msg.protoLiteCelValueConverter().findDefaultCelValue(msg.celType().name(), fieldName); + if (descDefault.isPresent()) { + return descDefault.get(); + } + + return NullValue.NULL_VALUE; + } + + if (obj instanceof RawProtoMessageLiteValue) { + RawProtoMessageLiteValue rawMsg = (RawProtoMessageLiteValue) obj; + ImmutableList unknowns = rawMsg.unknownFields().get(fieldNumber); + if (!unknowns.isEmpty()) { + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + unknowns, + FieldLiteDescriptor.Type.MESSAGE.getNumber(), + /* protoTypeName= */ fieldName, + /* isRepeated= */ false); + if (decoded != null) { + return decoded; + } + } + + return NullValue.NULL_VALUE; + } + + if (obj instanceof SelectableValue) { + @SuppressWarnings("unchecked") // Safe cast: SelectableValue keys on String + SelectableValue selectable = (SelectableValue) obj; + Optional found = selectable.find(fieldName); + return found.isPresent() ? found.get() : NullValue.NULL_VALUE; + } + + if (obj instanceof Map) { + Map map = (Map) obj; + Object mapVal = map.get(fieldName); + return mapVal != null ? mapVal : NullValue.NULL_VALUE; + } + + return NullValue.NULL_VALUE; + } + + private static LiteSubmessageQualifier create(int fieldNumber, String fieldName) { + return new LiteSubmessageQualifier(fieldNumber, fieldName); + } + + private LiteSubmessageQualifier(int fieldNumber, String fieldName) { + this.fieldNumber = fieldNumber; + this.fieldName = checkNotNull(fieldName); + } + } + + /** Step representing the terminal presence test step in {@code cel.@hasField}. */ + @Immutable + private static final class LitePresenceQualifier implements Step { + private final int fieldNumber; + private final String fieldName; + + @Override + public Object qualify(Object obj) { + if (obj == null || obj instanceof NullValue) { + return false; + } + + if (obj instanceof OptionalValue) { + OptionalValue opt = (OptionalValue) obj; + if (opt.isZeroValue()) { + return false; + } + obj = opt.value(); + if (obj == null || obj instanceof NullValue) { + return false; + } + } + + if (obj instanceof ProtoMessageLiteValue) { + ProtoMessageLiteValue msg = (ProtoMessageLiteValue) obj; + if (msg.fieldValues().containsKey(fieldName)) { + return true; + } + if (msg.unknownFields().containsKey(fieldNumber)) { + return true; + } + return false; + } + + if (obj instanceof RawProtoMessageLiteValue) { + RawProtoMessageLiteValue rawMsg = (RawProtoMessageLiteValue) obj; + return rawMsg.unknownFields().containsKey(fieldNumber); + } + + if (obj instanceof SelectableValue) { + @SuppressWarnings("unchecked") // Safe cast: SelectableValue keys on Object + SelectableValue selectable = (SelectableValue) obj; + return selectable.find(fieldName).isPresent(); + } + + if (obj instanceof Map) { + Map map = (Map) obj; + return map.containsKey(fieldName); + } + + return false; + } + + private static LitePresenceQualifier create(int fieldNumber, String fieldName) { + return new LitePresenceQualifier(fieldNumber, fieldName); + } + + private LitePresenceQualifier(int fieldNumber, String fieldName) { + this.fieldNumber = fieldNumber; + this.fieldName = checkNotNull(fieldName); + } + } + + /** + * Qualifies an attribute dynamically by applying the sequence of qualifiers in {@code + * qualifierLists}. + */ + @Internal + public static Object qualifyAttribute( + Object target, List qualifierLists, CelValueConverter celValueConverter) { + checkNotNull(qualifierLists); + checkNotNull(celValueConverter); + if (target == null) { + target = NullValue.NULL_VALUE; + } + + Object obj = celValueConverter.toRuntimeValue(target); + for (Object item : qualifierLists) { + if (!(item instanceof List)) { + throw new IllegalArgumentException("Expected qualifier list, got: " + item); + } + List qualifier = (List) item; + int fieldNumber = ((Number) qualifier.get(0)).intValue(); + String fieldName = (String) qualifier.get(1); + int typeCode = ((Number) qualifier.get(2)).intValue(); + Object defaultValue = qualifier.size() > 3 ? qualifier.get(3) : NullValue.NULL_VALUE; + Step step = LiteSelectQualifier.create(fieldNumber, fieldName, typeCode, defaultValue); + obj = step.qualify(obj); + obj = celValueConverter.toRuntimeValue(obj); + } + return celValueConverter.maybeUnwrap(obj); + } + + /** + * Tests presence of an attribute dynamically by navigating qualifiers in {@code qualifierLists} + * and checking presence at the final step. + */ + @Internal + public static boolean hasField( + Object target, List qualifierLists, CelValueConverter celValueConverter) { + checkNotNull(qualifierLists); + checkNotNull(celValueConverter); + if (target == null) { + return false; + } + + Object obj = celValueConverter.toRuntimeValue(target); + int size = qualifierLists.size(); + for (int i = 0; i < size; i++) { + Object item = qualifierLists.get(i); + if (!(item instanceof List)) { + throw new IllegalArgumentException("Expected qualifier list, got: " + item); + } + List qualifier = (List) item; + int fieldNumber = ((Number) qualifier.get(0)).intValue(); + String fieldName = (String) qualifier.get(1); + if (i < size - 1) { + Step step = LiteSubmessageQualifier.create(fieldNumber, fieldName); + obj = step.qualify(obj); + if (obj == null || obj instanceof NullValue) { + return false; + } + obj = celValueConverter.toRuntimeValue(obj); + } else { + Step step = LitePresenceQualifier.create(fieldNumber, fieldName); + Object result = step.qualify(obj); + return Objects.equals(result, true); + } + } + return false; + } + + private LiteAttributeStep() {} +} diff --git a/runtime/src/main/java/dev/cel/runtime/LiteRuntimeImpl.java b/runtime/src/main/java/dev/cel/runtime/LiteRuntimeImpl.java index 6572621a6..54c6e5352 100644 --- a/runtime/src/main/java/dev/cel/runtime/LiteRuntimeImpl.java +++ b/runtime/src/main/java/dev/cel/runtime/LiteRuntimeImpl.java @@ -33,6 +33,7 @@ import dev.cel.runtime.standard.CelStandardFunction; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -196,6 +197,9 @@ public CelLiteRuntime build() { } } + CelValueConverter valueConverter = celValueProvider.celValueConverter(); + registerAttributeBindings(functionBindingsBuilder, valueConverter); + functionBindingsBuilder.putAll(customFunctionBindings); DefaultDispatcher.Builder dispatcherBuilder = DefaultDispatcher.newBuilder(); @@ -262,6 +266,38 @@ public CelValueConverter celValueConverter() { this.lateBoundFunctionNamesBuilder = ImmutableSet.builder(); this.container = CelContainer.newBuilder().build(); } + + private void registerAttributeBindings( + ImmutableMap.Builder functionBindingsBuilder, + CelValueConverter valueConverter) { + CelFunctionBinding attributeBinding = + CelFunctionBinding.from( + "cel_attribute_list", + Object.class, + List.class, + (target, qualifiers) -> + LiteAttributeStep.qualifyAttribute(target, (List) qualifiers, valueConverter)); + for (CelFunctionBinding binding : + CelFunctionBinding.fromOverloads("cel.@attribute", attributeBinding)) { + if (!customFunctionBindings.containsKey(binding.getOverloadId())) { + functionBindingsBuilder.put(binding.getOverloadId(), binding); + } + } + + CelFunctionBinding hasFieldBinding = + CelFunctionBinding.from( + "cel_has_field_list", + Object.class, + List.class, + (target, qualifiers) -> + LiteAttributeStep.hasField(target, (List) qualifiers, valueConverter)); + for (CelFunctionBinding binding : + CelFunctionBinding.fromOverloads("cel.@hasField", hasFieldBinding)) { + if (!customFunctionBindings.containsKey(binding.getOverloadId())) { + functionBindingsBuilder.put(binding.getOverloadId(), binding); + } + } + } } static CelLiteRuntimeBuilder newBuilder() { diff --git a/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel b/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel index ca7665953..f838479ef 100644 --- a/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel +++ b/runtime/src/main/java/dev/cel/runtime/planner/BUILD.bazel @@ -121,6 +121,9 @@ java_library( "RelativeAttribute.java", "StringQualifier.java", ], + tags = [ + ], + visibility = ["//:internal"], deps = [ ":activation_wrapper", ":eval_helpers", @@ -650,6 +653,9 @@ cel_android_library( "RelativeAttribute.java", "StringQualifier.java", ], + tags = [ + ], + visibility = ["//:internal"], deps = [ ":activation_wrapper_android", ":eval_helpers_android", diff --git a/runtime/src/test/java/dev/cel/runtime/BUILD.bazel b/runtime/src/test/java/dev/cel/runtime/BUILD.bazel index a2e44223a..6a5425ede 100644 --- a/runtime/src/test/java/dev/cel/runtime/BUILD.bazel +++ b/runtime/src/test/java/dev/cel/runtime/BUILD.bazel @@ -40,6 +40,8 @@ java_library( "//common:options", "//common:proto_v1alpha1_ast", "//common/ast", + "//common/ast:cel_block", + "//common/exceptions:attribute_not_found", "//common/exceptions:bad_format", "//common/exceptions:divide_by_zero", "//common/exceptions:numeric_overflow", @@ -56,13 +58,19 @@ java_library( "//common/types:message_type_provider", "//common/values", "//common/values:cel_byte_string", + "//common/values:proto_message_lite_value", "//common/values:proto_message_lite_value_provider", "//compiler", "//compiler:compiler_builder", "//extensions", "//extensions:optional_library", + "//optimizer", + "//optimizer:optimizer_builder", + "//optimizer/optimizers:common_subexpression_elimination", + "//optimizer/optimizers:select_optimizer", "//parser:macro", "//parser:unparser", + "//protobuf:cel_lite_descriptor", "//runtime", "//runtime:activation", "//runtime:dispatcher", @@ -71,11 +79,12 @@ java_library( "//runtime:function_binding", "//runtime:interpretable", "//runtime:interpreter", - "//runtime:interpreter_util", "//runtime:late_function_binding", "//runtime:lite_runtime", "//runtime:lite_runtime_factory", + "//runtime:lite_runtime_impl", "//runtime:partial_vars", + "//runtime:program", "//runtime:proto_message_activation_factory", "//runtime:proto_message_runtime_equality", "//runtime:proto_message_runtime_helpers", @@ -183,6 +192,7 @@ cel_android_local_test( "//common/values:proto_message_lite_value_provider_android", "//extensions:lite_extensions_android", "//extensions:sets_function", + "//protobuf:cel_lite_descriptor", "//runtime:evaluation_exception", "//runtime:function_binding_android", "//runtime:late_function_binding_android", diff --git a/runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeAndroidTest.java b/runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeAndroidTest.java index 6c54ce486..4c11408b1 100644 --- a/runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeAndroidTest.java +++ b/runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeAndroidTest.java @@ -47,6 +47,8 @@ import dev.cel.expr.conformance.proto3.TestAllTypesCelLiteDescriptor; import dev.cel.extensions.CelLiteExtensions; import dev.cel.extensions.SetsFunction; +import dev.cel.protobuf.CelLiteDescriptor; +import dev.cel.protobuf.CelLiteDescriptor.MessageLiteDescriptor; import dev.cel.runtime.standard.EqualsOperator; import dev.cel.runtime.standard.IntFunction; import dev.cel.runtime.standard.IntFunction.IntOverload; @@ -124,7 +126,7 @@ public void toRuntimeBuilder_isNewInstance() { @Test public void toRuntimeBuilder_propertiesCopied() { - CelOptions celOptions = CelOptions.current().enableCelValue(true).build(); + CelOptions celOptions = CelOptions.current().build(); CelLiteRuntimeLibrary runtimeExtension = CelLiteExtensions.sets(celOptions, SetsFunction.INTERSECTS); CelValueProvider celValueProvider = ProtoMessageLiteValueProvider.newInstance(); @@ -157,10 +159,8 @@ public void toRuntimeBuilder_propertiesCopied() { @Test public void setCelOptions_unallowedOptionsSet_throws(@TestParameter CelOptionsTestCase testCase) { - assertThrows( - IllegalArgumentException.class, - () -> - CelLiteRuntimeFactory.newLiteRuntimeBuilder().setOptions(testCase.celOptions).build()); + CelLiteRuntimeBuilder builder = CelLiteRuntimeFactory.newLiteRuntimeBuilder(); + assertThrows(IllegalArgumentException.class, () -> builder.setOptions(testCase.celOptions)); } @Test @@ -170,7 +170,7 @@ public void standardEnvironment_disabledByDefault() throws Exception { CelAbstractSyntaxTree ast = readCheckedExpr("compiled_one_plus_two"); CelEvaluationException e = - assertThrows(CelEvaluationException.class, () -> runtime.createProgram(ast).eval()); + assertThrows(CelEvaluationException.class, () -> runtime.createProgram(ast)); assertThat(e) .hasMessageThat() .contains( @@ -206,6 +206,7 @@ public void eval_stringLiteral() throws Exception { } @Test + // CEL evaluation returns untyped Object which must be cast to List. @SuppressWarnings("unchecked") public void eval_listLiteral() throws Exception { CelLiteRuntime runtime = @@ -268,6 +269,7 @@ public void eval_primitiveVariables() throws Exception { } @Test + // CelFunctionBinding for List.class requires raw type binding. @SuppressWarnings("rawtypes") public void eval_customFunctions() throws Exception { CelLiteRuntime runtime = @@ -287,6 +289,7 @@ public void eval_customFunctions() throws Exception { } @Test + // CelFunctionBinding for List.class requires raw type binding. @SuppressWarnings("rawtypes") public void eval_customFunctions_asLateBoundFunctions() throws Exception { CelLiteRuntime runtime = @@ -333,6 +336,7 @@ public void eval_protoMessage_unknowns(String checkedExpr) throws Exception { @Test @TestParameters("{checkedExpr: 'compiled_proto2_select_primitives_all_ored'}") @TestParameters("{checkedExpr: 'compiled_proto3_select_primitives_all_ored'}") + @TestParameters("{checkedExpr: 'compiled_proto3_select_primitives_all_ored_optimized'}") public void eval_protoMessage_primitiveWithDefaults(String checkedExpr) throws Exception { CelLiteRuntime runtime = CelLiteRuntimeFactory.newLiteRuntimeBuilder() @@ -363,6 +367,7 @@ public void eval_protoMessage_primitiveWithDefaults(String checkedExpr) throws E @Test @TestParameters("{checkedExpr: 'compiled_proto2_select_primitives'}") @TestParameters("{checkedExpr: 'compiled_proto3_select_primitives'}") + @TestParameters("{checkedExpr: 'compiled_proto3_select_primitives_optimized'}") public void eval_protoMessage_primitives(String checkedExpr) throws Exception { CelLiteRuntime runtime = CelLiteRuntimeFactory.newLiteRuntimeBuilder() @@ -419,6 +424,128 @@ public void eval_protoMessage_primitives(String checkedExpr) throws Exception { assertThat(result).isTrue(); } + @Test + public void eval_protoMessage_selectOptimized_withRestrictedDescriptor_success() + throws Exception { + MessageLiteDescriptor restrictedMsgDesc = + new MessageLiteDescriptor( + "cel.expr.conformance.proto3.TestAllTypes", + ImmutableList.of(), + TestAllTypes::newBuilder); + CelLiteDescriptor restrictedDescriptor = + new CelLiteDescriptor("restricted", ImmutableList.of(restrictedMsgDesc)) {}; + + CelLiteRuntime runtime = + CelLiteRuntimeFactory.newLiteRuntimeBuilder() + .setStandardFunctions(CelStandardFunctions.ALL_STANDARD_FUNCTIONS) + .setValueProvider(ProtoMessageLiteValueProvider.newInstance(restrictedDescriptor)) + .build(); + + TestAllTypes proto3Msg = + TestAllTypes.newBuilder() + .setSingleInt32(1) + .setSingleInt64(2L) + .setSingleUint32(3) + .setSingleUint64(4L) + .setSingleSint32(5) + .setSingleSint64(6L) + .setSingleFixed32(7) + .setSingleFixed64(8L) + .setSingleSfixed32(9) + .setSingleSfixed64(10L) + .setSingleFloat(1.5f) + .setSingleDouble(2.5d) + .setSingleBool(true) + .setSingleString("hello world") + .setSingleBytes(ByteString.copyFromUtf8("abc")) + .build(); + + // Optimized expression succeeds via wire-byte decoding of unknown fields: + CelAbstractSyntaxTree optimizedAst = + readCheckedExpr("compiled_proto3_select_primitives_optimized"); + Program optimizedProgram = runtime.createProgram(optimizedAst); + boolean optimizedResult = (boolean) optimizedProgram.eval(ImmutableMap.of("proto3", proto3Msg)); + assertThat(optimizedResult).isTrue(); + + // Unoptimized expression fails because fields are missing from descriptor: + CelAbstractSyntaxTree unoptimizedAst = readCheckedExpr("compiled_proto3_select_primitives"); + Program unoptimizedProgram = runtime.createProgram(unoptimizedAst); + assertThrows( + CelEvaluationException.class, + () -> unoptimizedProgram.eval(ImmutableMap.of("proto3", proto3Msg))); + } + + @Test + public void eval_protoMessage_selectOptimized_withRestrictedDescriptor_returnsDefaults() + throws Exception { + MessageLiteDescriptor restrictedMsgDesc = + new MessageLiteDescriptor( + "cel.expr.conformance.proto3.TestAllTypes", + ImmutableList.of(), + TestAllTypes::newBuilder); + CelLiteDescriptor restrictedDescriptor = + new CelLiteDescriptor("restricted", ImmutableList.of(restrictedMsgDesc)) {}; + + CelLiteRuntime runtime = + CelLiteRuntimeFactory.newLiteRuntimeBuilder() + .setStandardFunctions(CelStandardFunctions.ALL_STANDARD_FUNCTIONS) + .setValueProvider(ProtoMessageLiteValueProvider.newInstance(restrictedDescriptor)) + .build(); + + // Optimized expression evaluates without exception, falling back to embedded default values: + CelAbstractSyntaxTree optimizedAst = + readCheckedExpr("compiled_proto3_select_primitives_all_ored_optimized"); + Program optimizedProgram = runtime.createProgram(optimizedAst); + boolean optimizedResult = + (boolean) + optimizedProgram.eval(ImmutableMap.of("proto3", TestAllTypes.getDefaultInstance())); + assertThat(optimizedResult).isFalse(); + + // Unoptimized expression fails because fields are missing from descriptor: + CelAbstractSyntaxTree unoptimizedAst = + readCheckedExpr("compiled_proto3_select_primitives_all_ored"); + Program unoptimizedProgram = runtime.createProgram(unoptimizedAst); + assertThrows( + CelEvaluationException.class, + () -> + unoptimizedProgram.eval(ImmutableMap.of("proto3", TestAllTypes.getDefaultInstance()))); + } + + @Test + public void eval_protoMessage_comprehension_withRestrictedDescriptor_success() throws Exception { + MessageLiteDescriptor restrictedMsgDesc = + new MessageLiteDescriptor( + "cel.expr.conformance.proto3.TestAllTypes", + ImmutableList.of(), + TestAllTypes::newBuilder); + CelLiteDescriptor restrictedDescriptor = + new CelLiteDescriptor("restricted", ImmutableList.of(restrictedMsgDesc)) {}; + + CelLiteRuntime runtime = + CelLiteRuntimeFactory.newLiteRuntimeBuilder() + .setStandardFunctions(CelStandardFunctions.ALL_STANDARD_FUNCTIONS) + .setValueProvider(ProtoMessageLiteValueProvider.newInstance(restrictedDescriptor)) + .build(); + + TestAllTypes proto3Msg = TestAllTypes.newBuilder().setSingleInt32(1).setSingleInt64(2L).build(); + + // Optimized expression succeeds via wire-byte decoding of unknown fields inside comprehension + // loop: + CelAbstractSyntaxTree optimizedAst = + readCheckedExpr("compiled_proto3_comprehension_exists_optimized"); + Program optimizedProgram = runtime.createProgram(optimizedAst); + boolean optimizedResult = (boolean) optimizedProgram.eval(ImmutableMap.of("proto3", proto3Msg)); + assertThat(optimizedResult).isTrue(); + + // Unoptimized expression fails because fields on the loop variable are missing from descriptor: + CelAbstractSyntaxTree unoptimizedAst = + readCheckedExpr("compiled_proto3_comprehension_exists_unoptimized"); + Program unoptimizedProgram = runtime.createProgram(unoptimizedAst); + assertThrows( + CelEvaluationException.class, + () -> unoptimizedProgram.eval(ImmutableMap.of("proto3", proto3Msg))); + } + @Test @TestParameters("{checkedExpr: 'compiled_proto2_select_wrappers'}") @TestParameters("{checkedExpr: 'compiled_proto3_select_wrappers'}") @@ -467,6 +594,7 @@ public void eval_protoMessage_wrappers(String checkedExpr) throws Exception { } @Test + // CEL evaluation returns untyped Object which must be cast to List. @SuppressWarnings("unchecked") @TestParameters("{checkedExpr: 'compiled_proto2_deep_traversal'}") @TestParameters("{checkedExpr: 'compiled_proto3_deep_traversal'}") @@ -498,6 +626,7 @@ public void eval_protoMessage_safeTraversal(String checkedExpr) throws Exception } @Test + // CEL evaluation returns untyped Object which must be cast to List. @SuppressWarnings("unchecked") @TestParameters("{checkedExpr: 'compiled_proto2_deep_traversal'}") @TestParameters("{checkedExpr: 'compiled_proto3_deep_traversal'}") @@ -546,6 +675,7 @@ public void eval_protoMessage_deepTraversalReturnsRepeatedStrings(String checked } @Test + // CEL evaluation returns untyped Object which must be cast to List. @SuppressWarnings("unchecked") @TestParameters("{checkedExpr: 'compiled_proto2_select_repeated_fields'}") @TestParameters("{checkedExpr: 'compiled_proto3_select_repeated_fields'}") @@ -728,6 +858,8 @@ public void eval_protoMessage_mapFields(String checkedExpr) throws Exception { .inOrder(); } + // Testing rejection of deprecated configuration options. + @SuppressWarnings("deprecation") private enum CelOptionsTestCase { UNSIGNED_LONG_DISABLED(newBaseTestOptions().enableUnsignedLongs(false).build()), UNWRAP_WKT_DISABLED(newBaseTestOptions().unwrapWellKnownTypesOnFunctionDispatch(false).build()), @@ -736,7 +868,7 @@ private enum CelOptionsTestCase { private final CelOptions celOptions; private static CelOptions.Builder newBaseTestOptions() { - return CelOptions.current().enableCelValue(true); + return CelOptions.current(); } CelOptionsTestCase(CelOptions celOptions) { diff --git a/runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeVersionSkewTest.java b/runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeVersionSkewTest.java new file mode 100644 index 000000000..a3f5cd921 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/CelLiteRuntimeVersionSkewTest.java @@ -0,0 +1,464 @@ +// 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.runtime; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.protobuf.ByteString; +import dev.cel.bundle.Cel; +import dev.cel.bundle.CelFactory; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelContainer; +import dev.cel.common.CelOptions; +import dev.cel.common.ast.CelBlock; +import dev.cel.common.types.ProtoMessageTypeProvider; +import dev.cel.common.types.StructTypeReference; +import dev.cel.common.values.CelByteString; +import dev.cel.common.values.ProtoMessageLiteValueProvider; +import dev.cel.expr.conformance.proto3.TestAllTypes; +import dev.cel.expr.conformance.proto3.TestAllTypes.NestedMessage; +import dev.cel.expr.conformance.proto3.TestAllTypesCelDescriptor; +import dev.cel.optimizer.CelOptimizer; +import dev.cel.optimizer.CelOptimizerFactory; +import dev.cel.optimizer.optimizers.SelectOptimizer; +import dev.cel.optimizer.optimizers.SelectOptimizer.SelectOptimizerOptions; +import dev.cel.optimizer.optimizers.SubexpressionOptimizer; +import dev.cel.optimizer.optimizers.SubexpressionOptimizer.SubexpressionOptimizerOptions; +import dev.cel.parser.CelStandardMacro; +import dev.cel.protobuf.CelLiteDescriptor; +import dev.cel.protobuf.CelLiteDescriptor.FieldLiteDescriptor; +import dev.cel.protobuf.CelLiteDescriptor.MessageLiteDescriptor; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CelLiteRuntimeVersionSkewTest { + + private static final CelContainer CEL_CONTAINER = + CelContainer.ofName("cel.expr.conformance.proto3"); + + private static final CelOptions CEL_OPTIONS = + CelOptions.current() + .populateMacroCalls(true) + .enableHeterogeneousNumericComparisons(true) + .build(); + + private Cel cel; + private CelOptimizer celOptimizer; + private CelLiteRuntime v1Runtime; + private ProtoMessageLiteValueProvider v1ValueProvider; + + @Before + public void setUp() { + // Schema V2 Compiler: includes single_int64 (field 2), single_nested_message (field 21), + // single_string (field 14), single_bool (field 13), single_bytes (field 15). + cel = + CelFactory.standardCelBuilder() + .setOptions(CEL_OPTIONS) + .setStandardMacros(CelStandardMacro.STANDARD_MACROS) + .addMessageTypes(TestAllTypes.getDescriptor()) + .addVar("msg", StructTypeReference.create(TestAllTypes.getDescriptor().getFullName())) + .setContainer(CEL_CONTAINER) + .build(); + + celOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers( + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile())) + .build(); + + // Schema V1 Runtime: restricted CelLiteDescriptor omitting fields 2, 13, 14, 15, and 21. + CelLiteDescriptor fullDescriptor = TestAllTypesCelDescriptor.getDescriptor(); + MessageLiteDescriptor fullMsgDesc = + fullDescriptor + .getProtoTypeNamesToDescriptors() + .get("cel.expr.conformance.proto3.TestAllTypes"); + + List v1Fields = new ArrayList<>(); + for (FieldLiteDescriptor f : fullMsgDesc.getFieldDescriptors()) { + String name = f.getFieldName(); + if (!name.equals("single_int64") + && !name.equals("single_nested_message") + && !name.equals("single_string") + && !name.equals("single_bool") + && !name.equals("single_bytes")) { + v1Fields.add(f); + } + } + + MessageLiteDescriptor v1MsgDesc = + new MessageLiteDescriptor( + fullMsgDesc.getProtoTypeName(), v1Fields, fullMsgDesc::newMessageBuilder); + + List allMsgDescs = new ArrayList<>(); + for (MessageLiteDescriptor d : fullDescriptor.getProtoTypeNamesToDescriptors().values()) { + if (d.getProtoTypeName().equals("cel.expr.conformance.proto3.TestAllTypes")) { + allMsgDescs.add(v1MsgDesc); + } else { + allMsgDescs.add(d); + } + } + + CelLiteDescriptor v1Descriptor = new CelLiteDescriptor("v1", allMsgDescs) {}; + v1ValueProvider = ProtoMessageLiteValueProvider.newInstance(v1Descriptor); + + v1Runtime = + CelLiteRuntimeFactory.newLiteRuntimeBuilder() + .setStandardFunctions(CelStandardFunctions.ALL_STANDARD_FUNCTIONS) + .setTypeProvider( + ProtoMessageTypeProvider.newBuilder() + .addDescriptors(ImmutableSet.of(TestAllTypes.getDescriptor())) + .build()) + .setValueProvider(v1ValueProvider) + .setContainer(CEL_CONTAINER) + .build(); + } + + private Object eval(String expression, TestAllTypes message) throws Exception { + CelAbstractSyntaxTree ast = cel.compile(expression).getAst(); + CelAbstractSyntaxTree optimizedAst = celOptimizer.optimize(ast); + Program program = v1Runtime.createProgram(optimizedAst); + return program.eval(ImmutableMap.of("msg", message)); + } + + @Test + public void select_unsetUnknownScalar_returnsBakedDefault() throws Exception { + TestAllTypes msg = TestAllTypes.getDefaultInstance(); + + Object result = eval("msg.single_int64", msg); + + assertThat(result).isEqualTo(0L); + } + + @Test + public void select_populatedUnknownScalar_decodesFromWireBytes() throws Exception { + TestAllTypes msg = TestAllTypes.newBuilder().setSingleInt64(42L).build(); + + Object result = eval("msg.single_int64", msg); + + assertThat(result).isEqualTo(42L); + } + + @Test + public void has_unknownScalar_whenPresentOnWire_returnsTrue() throws Exception { + TestAllTypes msg = TestAllTypes.newBuilder().setSingleInt64(42L).build(); + + Object result = eval("has(msg.single_int64)", msg); + + assertThat(result).isEqualTo(true); + } + + @Test + public void has_unknownScalar_whenAbsentOnWire_returnsFalse() throws Exception { + TestAllTypes msg = TestAllTypes.getDefaultInstance(); + + Object result = eval("has(msg.single_int64)", msg); + + assertThat(result).isEqualTo(false); + } + + @Test + public void select_unsetUnknownString_returnsBakedDefault() throws Exception { + TestAllTypes msg = TestAllTypes.getDefaultInstance(); + + Object result = eval("msg.single_string", msg); + + assertThat(result).isEqualTo(""); + } + + @Test + public void select_populatedUnknownString_decodesFromWireBytes() throws Exception { + TestAllTypes msg = TestAllTypes.newBuilder().setSingleString("cel-skew-test").build(); + + Object result = eval("msg.single_string", msg); + + assertThat(result).isEqualTo("cel-skew-test"); + } + + @Test + public void has_unknownString_whenPresentOnWire_returnsTrue() throws Exception { + TestAllTypes msg = TestAllTypes.newBuilder().setSingleString("present").build(); + + Object result = eval("has(msg.single_string)", msg); + + assertThat(result).isEqualTo(true); + } + + @Test + public void select_unsetUnknownBool_returnsBakedDefault() throws Exception { + TestAllTypes msg = TestAllTypes.getDefaultInstance(); + + Object result = eval("msg.single_bool", msg); + + assertThat(result).isEqualTo(false); + } + + @Test + public void select_populatedUnknownBool_decodesFromWireBytes() throws Exception { + TestAllTypes msg = TestAllTypes.newBuilder().setSingleBool(true).build(); + + Object result = eval("msg.single_bool", msg); + + assertThat(result).isEqualTo(true); + } + + @Test + public void select_unsetUnknownBytes_returnsBakedDefault() throws Exception { + TestAllTypes msg = TestAllTypes.getDefaultInstance(); + + Object result = eval("msg.single_bytes", msg); + + assertThat(result).isEqualTo(CelByteString.EMPTY); + } + + @Test + public void select_populatedUnknownBytes_decodesFromWireBytes() throws Exception { + TestAllTypes msg = + TestAllTypes.newBuilder().setSingleBytes(ByteString.copyFromUtf8("binary")).build(); + + Object result = eval("msg.single_bytes", msg); + + assertThat(result).isEqualTo(CelByteString.of("binary".getBytes(UTF_8))); + } + + @Test + public void select_populatedUnknownSubmessage_traversesWireBytes() throws Exception { + TestAllTypes msg = + TestAllTypes.newBuilder() + .setSingleNestedMessage(NestedMessage.newBuilder().setBb(123).build()) + .build(); + + Object result = eval("msg.single_nested_message.bb", msg); + + assertThat(result).isEqualTo(123L); + } + + @Test + public void select_unsetUnknownSubmessage_returnsBakedDefault() throws Exception { + TestAllTypes msg = TestAllTypes.getDefaultInstance(); + + Object result = eval("msg.single_nested_message.bb", msg); + + assertThat(result).isEqualTo(0L); + } + + @Test + public void has_unknownSubmessageField_whenPopulated_returnsTrue() throws Exception { + TestAllTypes msg = + TestAllTypes.newBuilder() + .setSingleNestedMessage(NestedMessage.newBuilder().setBb(123).build()) + .build(); + + Object result = eval("has(msg.single_nested_message.bb)", msg); + + assertThat(result).isEqualTo(true); + } + + @Test + public void has_unknownSubmessageField_whenSubmessageUnset_returnsFalse() throws Exception { + TestAllTypes msg = TestAllTypes.getDefaultInstance(); + + Object result = eval("has(msg.single_nested_message.bb)", msg); + + assertThat(result).isEqualTo(false); + } + + @Test + public void has_unknownSubmessageField_whenSubmessagePresentButFieldUnset_returnsFalse() + throws Exception { + TestAllTypes msg = + TestAllTypes.newBuilder() + .setSingleNestedMessage(NestedMessage.getDefaultInstance()) + .build(); + + Object result = eval("has(msg.single_nested_message.bb)", msg); + + assertThat(result).isEqualTo(false); + } + + @Test + public void mixedExpression_versionSkewFieldWithCondition() throws Exception { + TestAllTypes msg = + TestAllTypes.newBuilder() + .setSingleInt64(100L) + .setSingleString("alpha") + .setSingleBool(true) + .setSingleNestedMessage(NestedMessage.newBuilder().setBb(50).build()) + .build(); + + Object result = + eval( + "msg.single_int64 > 50 && msg.single_bool && msg.single_string == 'alpha' &&" + + " has(msg.single_nested_message) && msg.single_nested_message.bb == 50", + msg); + + assertThat(result).isEqualTo(true); + } + + @Test + public void fallbackBinding_directCall_qualifiesAttribute() throws Exception { + TestAllTypes msg = TestAllTypes.newBuilder().setSingleInt64(42L).build(); + ImmutableList qualifierList = ImmutableList.of(ImmutableList.of(2, "single_int64", 3, 0L)); + + Object result = + LiteAttributeStep.qualifyAttribute(msg, qualifierList, v1ValueProvider.celValueConverter()); + + assertThat(result).isEqualTo(42L); + } + + @Test + public void fallbackBinding_directCall_hasField() throws Exception { + TestAllTypes msg = TestAllTypes.newBuilder().setSingleInt64(42L).build(); + ImmutableList qualifierList = ImmutableList.of(ImmutableList.of(2, "single_int64")); + + boolean hasPresent = + LiteAttributeStep.hasField(msg, qualifierList, v1ValueProvider.celValueConverter()); + boolean hasAbsent = + LiteAttributeStep.hasField( + TestAllTypes.getDefaultInstance(), qualifierList, v1ValueProvider.celValueConverter()); + + assertThat(hasPresent).isTrue(); + assertThat(hasAbsent).isFalse(); + } + + @Test + public void celBlock_subexpressionThenSelectOptimizer_evaluatesRepeatedUnknownScalar() + throws Exception { + CelOptimizer blockOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers( + SubexpressionOptimizer.getInstance(), + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile())) + .build(); + + CelAbstractSyntaxTree ast = + cel.compile("msg.single_int64 > 10 && msg.single_int64 < 100").getAst(); + CelAbstractSyntaxTree optimizedAst = blockOptimizer.optimize(ast); + + assertThat(CelBlock.extract(optimizedAst)).isPresent(); + + Program program = v1Runtime.createProgram(optimizedAst); + Object match = + program.eval(ImmutableMap.of("msg", TestAllTypes.newBuilder().setSingleInt64(42L).build())); + Object noMatch = program.eval(ImmutableMap.of("msg", TestAllTypes.getDefaultInstance())); + + assertThat(match).isEqualTo(true); + assertThat(noMatch).isEqualTo(false); + } + + @Test + public void celBlock_subexpressionThenSelectOptimizer_evaluatesRepeatedUnknownSubmessage() + throws Exception { + CelOptimizer blockOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers( + SubexpressionOptimizer.getInstance(), + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile())) + .build(); + + CelAbstractSyntaxTree ast = + cel.compile("msg.single_nested_message.bb > 10 && msg.single_nested_message.bb < 200") + .getAst(); + CelAbstractSyntaxTree optimizedAst = blockOptimizer.optimize(ast); + + assertThat(CelBlock.extract(optimizedAst)).isPresent(); + + Program program = v1Runtime.createProgram(optimizedAst); + TestAllTypes populatedMsg = + TestAllTypes.newBuilder() + .setSingleNestedMessage(NestedMessage.newBuilder().setBb(123).build()) + .build(); + Object match = program.eval(ImmutableMap.of("msg", populatedMsg)); + Object noMatch = program.eval(ImmutableMap.of("msg", TestAllTypes.getDefaultInstance())); + + assertThat(match).isEqualTo(true); + assertThat(noMatch).isEqualTo(false); + } + + @Test + public void celBlock_subexpressionThenSelectOptimizer_sharedSubmessageDifferentFields() + throws Exception { + CelOptimizer blockOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers( + SubexpressionOptimizer.getInstance(), + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile())) + .build(); + + CelAbstractSyntaxTree ast = + cel.compile("has(msg.single_nested_message.bb) && msg.single_nested_message.bb == 50") + .getAst(); + CelAbstractSyntaxTree optimizedAst = blockOptimizer.optimize(ast); + + assertThat(CelBlock.extract(optimizedAst)).isPresent(); + + Program program = v1Runtime.createProgram(optimizedAst); + TestAllTypes populatedMsg = + TestAllTypes.newBuilder() + .setSingleNestedMessage(NestedMessage.newBuilder().setBb(50).build()) + .build(); + Object match = program.eval(ImmutableMap.of("msg", populatedMsg)); + Object noMatch = program.eval(ImmutableMap.of("msg", TestAllTypes.getDefaultInstance())); + + assertThat(match).isEqualTo(true); + assertThat(noMatch).isEqualTo(false); + } + + @Test + public void celBlock_selectThenSubexpressionOptimizer_eliminatesAttributeCalls() + throws Exception { + CelOptimizer blockOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers( + SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + TestAllTypes.getDescriptor().getFile()), + SubexpressionOptimizer.newInstance( + SubexpressionOptimizerOptions.newBuilder() + .addEliminableFunctions("cel.@attribute", "cel.@hasField") + .build())) + .build(); + + CelAbstractSyntaxTree ast = + cel.compile("msg.single_int64 > 10 && msg.single_int64 < 100").getAst(); + CelAbstractSyntaxTree optimizedAst = blockOptimizer.optimize(ast); + + assertThat(CelBlock.extract(optimizedAst)).isPresent(); + + Program program = v1Runtime.createProgram(optimizedAst); + Object match = + program.eval(ImmutableMap.of("msg", TestAllTypes.newBuilder().setSingleInt64(42L).build())); + Object noMatch = program.eval(ImmutableMap.of("msg", TestAllTypes.getDefaultInstance())); + + assertThat(match).isEqualTo(true); + assertThat(noMatch).isEqualTo(false); + } +} diff --git a/runtime/src/test/java/dev/cel/runtime/LiteAttributeStepTest.java b/runtime/src/test/java/dev/cel/runtime/LiteAttributeStepTest.java new file mode 100644 index 000000000..60ba36685 --- /dev/null +++ b/runtime/src/test/java/dev/cel/runtime/LiteAttributeStepTest.java @@ -0,0 +1,703 @@ +// 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.runtime; + +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.protobuf.ByteString; +import com.google.protobuf.CodedOutputStream; +import com.google.protobuf.ExtensionRegistryLite; +import dev.cel.common.exceptions.CelAttributeNotFoundException; +import dev.cel.common.values.NullValue; +import dev.cel.common.values.OptionalValue; +import dev.cel.common.values.ProtoLiteCelValueConverter; +import dev.cel.common.values.ProtoMessageLiteValue; +import dev.cel.common.values.ProtoMessageLiteValueProvider; +import dev.cel.common.values.RawProtoMessageLiteValue; +import dev.cel.common.values.SelectableValue; +import dev.cel.expr.conformance.proto3.TestAllTypes; +import dev.cel.expr.conformance.proto3.TestAllTypesCelDescriptor; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.NoSuchElementException; +import java.util.Optional; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class LiteAttributeStepTest { + + private static final ProtoLiteCelValueConverter CONVERTER = + (ProtoLiteCelValueConverter) + ProtoMessageLiteValueProvider.newInstance(TestAllTypesCelDescriptor.getDescriptor()) + .protoCelValueConverter(); + + private static final class TestSelectableValue implements SelectableValue { + private final ImmutableMap values; + + TestSelectableValue(ImmutableMap values) { + this.values = values; + } + + @Override + public Object select(String field) { + if (values.containsKey(field)) { + return values.get(field); + } + throw new NoSuchElementException("Field not found: " + field); + } + + @Override + public Optional find(String field) { + return Optional.ofNullable(values.get(field)); + } + } + + private static ProtoMessageLiteValue createProtoMessageWithUnknowns( + TestAllTypes knownMessage, byte[] unknownBytes) throws IOException { + ByteArrayOutputStream combined = new ByteArrayOutputStream(); + knownMessage.writeTo(combined); + combined.write(unknownBytes); + TestAllTypes parsed = + TestAllTypes.parseFrom(combined.toByteArray(), ExtensionRegistryLite.getEmptyRegistry()); + return ProtoMessageLiteValue.create( + parsed, "cel.expr.conformance.proto3.TestAllTypes", CONVERTER); + } + + @Test + public void qualifyAttribute_nullTarget_returnsDefaultValue() { + Object result = + LiteAttributeStep.qualifyAttribute( + null, ImmutableList.of(ImmutableList.of(1, "field", 9, "default_val")), CONVERTER); + + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void qualifyAttribute_nullValueTarget_returnsDefaultValue() { + Object result = + LiteAttributeStep.qualifyAttribute( + NullValue.NULL_VALUE, + ImmutableList.of(ImmutableList.of(1, "field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void qualifyAttribute_nullDefaultValue_defaultsToNullValue() { + Object result = + LiteAttributeStep.qualifyAttribute( + null, ImmutableList.of(Arrays.asList(1, "missing", 9, null)), CONVERTER); + + assertThat(result).isEqualTo(NullValue.NULL_VALUE); + } + + @Test + public void qualifyAttribute_emptyOptional_returnsEmptyOptional() { + Object result = + LiteAttributeStep.qualifyAttribute( + OptionalValue.EMPTY, + ImmutableList.of(ImmutableList.of(1, "field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo(Optional.empty()); + } + + @Test + public void qualifyAttribute_optionalContainingNullValue_returnsDefaultValue() { + Object result = + LiteAttributeStep.qualifyAttribute( + OptionalValue.create(NullValue.NULL_VALUE), + ImmutableList.of(ImmutableList.of(1, "field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void qualifyAttribute_optionalPresent_unwrapsAndQualifies() { + Object result = + LiteAttributeStep.qualifyAttribute( + OptionalValue.create(ImmutableMap.of("field", "present_val")), + ImmutableList.of(ImmutableList.of(1, "field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("present_val"); + } + + @Test + public void qualifyAttribute_protoMessageLite_knownFieldValue() { + TestAllTypes proto = TestAllTypes.newBuilder().setSingleString("known_val").build(); + ProtoMessageLiteValue message = + ProtoMessageLiteValue.create(proto, "cel.expr.conformance.proto3.TestAllTypes", CONVERTER); + + Object result = + LiteAttributeStep.qualifyAttribute( + message, + ImmutableList.of(ImmutableList.of(14, "single_string", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("known_val"); + } + + @Test + public void qualifyAttribute_protoMessageLite_unknownFieldValue() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeString(999, "unknown_val"); + cos.flush(); + + ProtoMessageLiteValue message = + createProtoMessageWithUnknowns(TestAllTypes.getDefaultInstance(), baos.toByteArray()); + + Object result = + LiteAttributeStep.qualifyAttribute( + message, + ImmutableList.of(ImmutableList.of(999, "unknown_field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("unknown_val"); + } + + @Test + public void qualifyAttribute_protoMessageLite_missingFieldReturnsDefault() { + ProtoMessageLiteValue message = + ProtoMessageLiteValue.create( + TestAllTypes.getDefaultInstance(), + "cel.expr.conformance.proto3.TestAllTypes", + CONVERTER); + + Object result = + LiteAttributeStep.qualifyAttribute( + message, + ImmutableList.of(ImmutableList.of(9999, "missing_field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void qualifyAttribute_rawProtoMessageLite_unknownFieldValue() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeString(10, "raw_val"); + cos.flush(); + + RawProtoMessageLiteValue rawMessage = + RawProtoMessageLiteValue.create(ByteString.copyFrom(baos.toByteArray())); + + Object result = + LiteAttributeStep.qualifyAttribute( + rawMessage, + ImmutableList.of(ImmutableList.of(10, "raw_field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("raw_val"); + } + + @Test + public void qualifyAttribute_rawProtoMessageLite_missingFieldReturnsDefault() { + RawProtoMessageLiteValue rawMessage = RawProtoMessageLiteValue.create(ByteString.EMPTY); + + Object result = + LiteAttributeStep.qualifyAttribute( + rawMessage, + ImmutableList.of(ImmutableList.of(99, "missing_field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void qualifyAttribute_selectableValue_present() { + TestSelectableValue selectable = + new TestSelectableValue(ImmutableMap.of("field", "selectable_val")); + + Object result = + LiteAttributeStep.qualifyAttribute( + selectable, + ImmutableList.of(ImmutableList.of(1, "field", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("selectable_val"); + } + + @Test + public void qualifyAttribute_selectableValue_absentReturnsDefault() { + TestSelectableValue selectable = new TestSelectableValue(ImmutableMap.of()); + + Object result = + LiteAttributeStep.qualifyAttribute( + selectable, + ImmutableList.of(ImmutableList.of(1, "missing", 9, "default_val")), + CONVERTER); + + assertThat(result).isEqualTo("default_val"); + } + + @Test + public void qualifyAttribute_map_present() { + ImmutableMap map = ImmutableMap.of("key", "map_val"); + + Object result = + LiteAttributeStep.qualifyAttribute( + map, ImmutableList.of(ImmutableList.of(1, "key", 9, "default_val")), CONVERTER); + + assertThat(result).isEqualTo("map_val"); + } + + @Test + public void qualifyAttribute_map_nullValueReturnsNullValue() { + ImmutableMap map = ImmutableMap.of("key", NullValue.NULL_VALUE); + + Object result = + LiteAttributeStep.qualifyAttribute( + map, ImmutableList.of(ImmutableList.of(1, "key", 9, "default_val")), CONVERTER); + + assertThat(result).isEqualTo(NullValue.NULL_VALUE); + } + + @Test + public void qualifyAttribute_map_missingKeyThrowsException() { + ImmutableMap map = ImmutableMap.of(); + + assertThrows( + CelAttributeNotFoundException.class, + () -> + LiteAttributeStep.qualifyAttribute( + map, + ImmutableList.of(ImmutableList.of(1, "missing", 9, "default_val")), + CONVERTER)); + } + + @Test + public void qualifyAttribute_unsupportedTargetThrowsException() { + assertThrows( + CelAttributeNotFoundException.class, + () -> + LiteAttributeStep.qualifyAttribute( + 12345, + ImmutableList.of(ImmutableList.of(1, "field", 9, "default_val")), + CONVERTER)); + } + + @Test + public void qualifyAttribute_invalidQualifierElementThrowsException() { + assertThrows( + IllegalArgumentException.class, + () -> + LiteAttributeStep.qualifyAttribute( + ImmutableMap.of("field", "val"), + ImmutableList.of("invalid_non_list_qualifier"), + CONVERTER)); + } + + @Test + public void qualifyAttribute_multiStepChaining() throws Exception { + ByteArrayOutputStream subBaos = new ByteArrayOutputStream(); + CodedOutputStream subCos = CodedOutputStream.newInstance(subBaos); + subCos.writeString(20, "nested_val"); + subCos.flush(); + ByteString subBytes = ByteString.copyFrom(subBaos.toByteArray()); + + ByteArrayOutputStream rootBaos = new ByteArrayOutputStream(); + CodedOutputStream rootCos = CodedOutputStream.newInstance(rootBaos); + rootCos.writeBytes(999, subBytes); + rootCos.flush(); + + ProtoMessageLiteValue rootMessage = + createProtoMessageWithUnknowns(TestAllTypes.getDefaultInstance(), rootBaos.toByteArray()); + + Object result = + LiteAttributeStep.qualifyAttribute( + rootMessage, + ImmutableList.of( + ImmutableList.of(999, "unknown_submessage", 11, NullValue.NULL_VALUE), + ImmutableList.of(20, "nested_field", 9, "default")), + CONVERTER); + + assertThat(result).isEqualTo("nested_val"); + } + + @Test + public void hasField_nullTarget_returnsFalse() { + boolean result = + LiteAttributeStep.hasField(null, ImmutableList.of(ImmutableList.of(1, "field")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_nullValueTarget_returnsFalse() { + boolean result = + LiteAttributeStep.hasField( + NullValue.NULL_VALUE, ImmutableList.of(ImmutableList.of(1, "field")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_emptyOptional_returnsFalse() { + boolean result = + LiteAttributeStep.hasField( + OptionalValue.EMPTY, ImmutableList.of(ImmutableList.of(1, "field")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_optionalContainingNullValue_returnsFalse() { + boolean result = + LiteAttributeStep.hasField( + OptionalValue.create(NullValue.NULL_VALUE), + ImmutableList.of(ImmutableList.of(1, "field")), + CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_optionalPresent_unwrapsAndTestsPresence() { + boolean result = + LiteAttributeStep.hasField( + OptionalValue.create(ImmutableMap.of("field", "val")), + ImmutableList.of(ImmutableList.of(1, "field")), + CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_optionalContainingProtoWithUnknownField_returnsTrue() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt64(999, 12345L); + cos.flush(); + + ProtoMessageLiteValue message = + createProtoMessageWithUnknowns(TestAllTypes.getDefaultInstance(), baos.toByteArray()); + + boolean result = + LiteAttributeStep.hasField( + OptionalValue.create(message), + ImmutableList.of(ImmutableList.of(999, "unknown_field")), + CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_optionalContainingRawProto_returnsTrue() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt64(10, 42L); + cos.flush(); + + RawProtoMessageLiteValue rawMessage = + RawProtoMessageLiteValue.create(ByteString.copyFrom(baos.toByteArray())); + + boolean result = + LiteAttributeStep.hasField( + OptionalValue.create(rawMessage), + ImmutableList.of(ImmutableList.of(10, "raw_field")), + CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_protoMessageLite_knownFieldReturnsTrue() { + TestAllTypes proto = TestAllTypes.newBuilder().setSingleString("val").build(); + ProtoMessageLiteValue message = + ProtoMessageLiteValue.create(proto, "cel.expr.conformance.proto3.TestAllTypes", CONVERTER); + + boolean result = + LiteAttributeStep.hasField( + message, ImmutableList.of(ImmutableList.of(14, "single_string")), CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_protoMessageLite_unknownFieldReturnsTrue() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt64(999, 12345L); + cos.flush(); + + ProtoMessageLiteValue message = + createProtoMessageWithUnknowns(TestAllTypes.getDefaultInstance(), baos.toByteArray()); + + boolean result = + LiteAttributeStep.hasField( + message, ImmutableList.of(ImmutableList.of(999, "unknown_field")), CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_protoMessageLite_absentReturnsFalse() { + ProtoMessageLiteValue message = + ProtoMessageLiteValue.create( + TestAllTypes.getDefaultInstance(), + "cel.expr.conformance.proto3.TestAllTypes", + CONVERTER); + + boolean result = + LiteAttributeStep.hasField( + message, ImmutableList.of(ImmutableList.of(9999, "missing_field")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_rawProtoMessageLite_unknownFieldReturnsTrue() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + CodedOutputStream cos = CodedOutputStream.newInstance(baos); + cos.writeInt64(10, 42L); + cos.flush(); + + RawProtoMessageLiteValue rawMessage = + RawProtoMessageLiteValue.create(ByteString.copyFrom(baos.toByteArray())); + + boolean result = + LiteAttributeStep.hasField( + rawMessage, ImmutableList.of(ImmutableList.of(10, "field")), CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_rawProtoMessageLite_absentReturnsFalse() { + RawProtoMessageLiteValue rawMessage = RawProtoMessageLiteValue.create(ByteString.EMPTY); + + boolean result = + LiteAttributeStep.hasField( + rawMessage, ImmutableList.of(ImmutableList.of(99, "missing_field")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_selectableValue_presentReturnsTrue() { + TestSelectableValue selectable = new TestSelectableValue(ImmutableMap.of("field", "val")); + + boolean result = + LiteAttributeStep.hasField( + selectable, ImmutableList.of(ImmutableList.of(1, "field")), CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_selectableValue_absentReturnsFalse() { + TestSelectableValue selectable = new TestSelectableValue(ImmutableMap.of()); + + boolean result = + LiteAttributeStep.hasField( + selectable, ImmutableList.of(ImmutableList.of(1, "field")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_map_presentReturnsTrue() { + ImmutableMap map = ImmutableMap.of("key", "val"); + + boolean result = + LiteAttributeStep.hasField(map, ImmutableList.of(ImmutableList.of(1, "key")), CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_map_absentReturnsFalse() { + ImmutableMap map = ImmutableMap.of(); + + boolean result = + LiteAttributeStep.hasField( + map, ImmutableList.of(ImmutableList.of(1, "missing")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_unsupportedTarget_returnsFalse() { + boolean result = + LiteAttributeStep.hasField( + "unsupported_string", ImmutableList.of(ImmutableList.of(1, "field")), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_invalidQualifierThrowsException() { + assertThrows( + IllegalArgumentException.class, + () -> + LiteAttributeStep.hasField( + ImmutableMap.of("field", "val"), + ImmutableList.of("invalid_non_list_qualifier"), + CONVERTER)); + } + + @Test + public void hasField_emptyQualifiersReturnsFalse() { + boolean result = + LiteAttributeStep.hasField(ImmutableMap.of("field", "val"), ImmutableList.of(), CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_multiStepChaining_intermediateRawProto_present() throws Exception { + ByteArrayOutputStream leafBaos = new ByteArrayOutputStream(); + CodedOutputStream leafCos = CodedOutputStream.newInstance(leafBaos); + leafCos.writeInt64(20, 100L); + leafCos.flush(); + ByteString leafBytes = ByteString.copyFrom(leafBaos.toByteArray()); + + ByteArrayOutputStream childBaos = new ByteArrayOutputStream(); + CodedOutputStream childCos = CodedOutputStream.newInstance(childBaos); + childCos.writeBytes(15, leafBytes); + childCos.flush(); + ByteString childBytes = ByteString.copyFrom(childBaos.toByteArray()); + + ByteArrayOutputStream parentBaos = new ByteArrayOutputStream(); + CodedOutputStream parentCos = CodedOutputStream.newInstance(parentBaos); + parentCos.writeBytes(10, childBytes); + parentCos.flush(); + + RawProtoMessageLiteValue parent = + RawProtoMessageLiteValue.create(ByteString.copyFrom(parentBaos.toByteArray())); + + boolean result = + LiteAttributeStep.hasField( + parent, + ImmutableList.of( + ImmutableList.of(10, "child"), + ImmutableList.of(15, "leaf"), + ImmutableList.of(20, "val_field")), + CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_multiStepChaining_intermediateRawProto_absent() throws Exception { + ByteArrayOutputStream childBaos = new ByteArrayOutputStream(); + CodedOutputStream childCos = CodedOutputStream.newInstance(childBaos); + childCos.writeString(99, "other"); + childCos.flush(); + ByteString childBytes = ByteString.copyFrom(childBaos.toByteArray()); + + ByteArrayOutputStream parentBaos = new ByteArrayOutputStream(); + CodedOutputStream parentCos = CodedOutputStream.newInstance(parentBaos); + parentCos.writeBytes(10, childBytes); + parentCos.flush(); + + RawProtoMessageLiteValue parent = + RawProtoMessageLiteValue.create(ByteString.copyFrom(parentBaos.toByteArray())); + + boolean result = + LiteAttributeStep.hasField( + parent, + ImmutableList.of( + ImmutableList.of(10, "child"), + ImmutableList.of(15, "missing_leaf"), + ImmutableList.of(20, "val_field")), + CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_multiStepChaining_intermediateProtoMessageLite_absent() { + ProtoMessageLiteValue rootMessage = + ProtoMessageLiteValue.create( + TestAllTypes.getDefaultInstance(), + "cel.expr.conformance.proto3.TestAllTypes", + CONVERTER); + + boolean result = + LiteAttributeStep.hasField( + rootMessage, + ImmutableList.of( + ImmutableList.of(9999, "missing_sub_message"), ImmutableList.of(20, "field")), + CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_multiStepChaining_intermediateSelectableValue_present() { + TestSelectableValue child = new TestSelectableValue(ImmutableMap.of("leaf", "val")); + TestSelectableValue parent = new TestSelectableValue(ImmutableMap.of("child", child)); + + boolean result = + LiteAttributeStep.hasField( + parent, + ImmutableList.of(ImmutableList.of(1, "child"), ImmutableList.of(2, "leaf")), + CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_multiStepChaining_intermediateSelectableValue_absent() { + TestSelectableValue parent = new TestSelectableValue(ImmutableMap.of()); + + boolean result = + LiteAttributeStep.hasField( + parent, + ImmutableList.of(ImmutableList.of(1, "missing_child"), ImmutableList.of(2, "leaf")), + CONVERTER); + + assertThat(result).isFalse(); + } + + @Test + public void hasField_multiStepChaining_intermediateMap_present() { + ImmutableMap child = ImmutableMap.of("leaf", "val"); + ImmutableMap parent = ImmutableMap.of("child", child); + + boolean result = + LiteAttributeStep.hasField( + parent, + ImmutableList.of(ImmutableList.of(1, "child"), ImmutableList.of(2, "leaf")), + CONVERTER); + + assertThat(result).isTrue(); + } + + @Test + public void hasField_multiStepChaining_intermediateMap_absent() { + ImmutableMap parent = ImmutableMap.of(); + + boolean result = + LiteAttributeStep.hasField( + parent, + ImmutableList.of(ImmutableList.of(1, "missing_child"), ImmutableList.of(2, "leaf")), + CONVERTER); + + assertThat(result).isFalse(); + } +} diff --git a/testing/src/main/java/dev/cel/testing/compiled/BUILD.bazel b/testing/src/main/java/dev/cel/testing/compiled/BUILD.bazel index 5ef4d8878..f8249fd54 100644 --- a/testing/src/main/java/dev/cel/testing/compiled/BUILD.bazel +++ b/testing/src/main/java/dev/cel/testing/compiled/BUILD.bazel @@ -44,6 +44,7 @@ java_library( resources = [ ":compiled_comprehension", ":compiled_comprehension_exists", + ":compiled_constant_folding", ":compiled_custom_functions", ":compiled_extended_env", ":compiled_extensions", @@ -57,16 +58,33 @@ java_library( ":compiled_proto2_select_primitives_all_ored", ":compiled_proto2_select_repeated_fields", ":compiled_proto2_select_wrappers", + ":compiled_proto3_comprehension_exists_optimized", + ":compiled_proto3_comprehension_exists_unoptimized", ":compiled_proto3_deep_traversal", ":compiled_proto3_select_map_fields", ":compiled_proto3_select_primitives", ":compiled_proto3_select_primitives_all_ored", + ":compiled_proto3_select_primitives_all_ored_optimized", + ":compiled_proto3_select_primitives_optimized", ":compiled_proto3_select_repeated_fields", ":compiled_proto3_select_wrappers", ":compiled_proto_message", + ":compiled_subexpression_elimination", ], ) +compile_cel( + name = "compiled_constant_folding", + constant_folding = True, + expression = "1 + 2 + 3", +) + +compile_cel( + name = "compiled_subexpression_elimination", + expression = "size('a') + size('a') == 2", + subexpression_elimination = True, +) + compile_cel( name = "compiled_hello_world", expression = "'hello world'", @@ -172,6 +190,28 @@ compile_cel( proto_srcs = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], ) +compile_cel( + name = "compiled_proto3_select_primitives_all_ored_optimized", + environment = "//testing/environment:proto3_message_variables", + expression = "proto3.single_int32 == 1 || proto3.single_int64 == 2 || proto3.single_uint32 == 3u || proto3.single_uint64 == 4u ||" + + "proto3.single_sint32 == 5 || proto3.single_sint64 == 6 || proto3.single_fixed32 == 7u || proto3.single_fixed64 == 8u ||" + + "proto3.single_sfixed32 == 9 || proto3.single_sfixed64 == 10 || proto3.single_float == 1.5 || proto3.single_double == 2.5 ||" + + "proto3.single_bool || proto3.single_string == 'hello world' || proto3.single_bytes == b\'abc\'", + optimize_field_selection = True, + proto_srcs = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], +) + +compile_cel( + name = "compiled_proto3_select_primitives_optimized", + environment = "//testing/environment:proto3_message_variables", + expression = "proto3.single_int32 == 1 && proto3.single_int64 == 2 && proto3.single_uint32 == 3u && proto3.single_uint64 == 4u &&" + + "proto3.single_sint32 == 5 && proto3.single_sint64 == 6 && proto3.single_fixed32 == 7u && proto3.single_fixed64 == 8u &&" + + "proto3.single_sfixed32 == 9 && proto3.single_sfixed64 == 10 && proto3.single_float == 1.5 && proto3.single_double == 2.5 &&" + + "proto3.single_bool && proto3.single_string == 'hello world' && proto3.single_bytes == b\'abc\'", + optimize_field_selection = True, + proto_srcs = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], +) + compile_cel( name = "compiled_proto3_select_wrappers", environment = "//testing/environment:proto3_message_variables", @@ -230,3 +270,18 @@ compile_cel( "proto3.map_bool_duration, proto3.map_bool_timestamp]", proto_srcs = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], ) + +compile_cel( + name = "compiled_proto3_comprehension_exists_optimized", + environment = "//testing/environment:proto3_message_variables", + expression = "[proto3].exists(m, m.single_int32 == 1 && m.single_int64 == 2)", + optimize_field_selection = True, + proto_srcs = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], +) + +compile_cel( + name = "compiled_proto3_comprehension_exists_unoptimized", + environment = "//testing/environment:proto3_message_variables", + expression = "[proto3].exists(m, m.single_int32 == 1 && m.single_int64 == 2)", + proto_srcs = ["@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_proto"], +)