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..ad97549f9 --- /dev/null +++ b/common/src/main/java/dev/cel/common/values/RawProtoMessageLiteValue.java @@ -0,0 +1,251 @@ +// 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.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 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 { + + private static final ImmutableList FIELD_TYPES = + ImmutableList.copyOf(WireFormat.FieldType.values()); + + private static WireFormat.FieldType getFieldType(int typeCode) { + if (typeCode < 1 || typeCode > FIELD_TYPES.size()) { + throw new IllegalArgumentException("Unsupported proto type code: " + typeCode); + } + return FIELD_TYPES.get(typeCode - 1); + } + + 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 = getFieldType(typeCode); + 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 = null; + for (Object item : entries) { + last = item; + } + return decodeWireValue(last, fieldType, protoTypeName); + } + + public static Object decodeWireValue(Object raw, int typeCode, String protoTypeName) { + return decodeWireValue(raw, getFieldType(typeCode), 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 (Long) 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..0d22c186a 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", 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..66d3668c5 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,25 @@ 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().get(999)).containsExactly(12345L); + assertThat(messageLiteValue.unknownFields().get(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..2c10be652 --- /dev/null +++ b/common/src/test/java/dev/cel/common/values/RawProtoMessageLiteValueTest.java @@ -0,0 +1,496 @@ +// 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 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 java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class RawProtoMessageLiteValueTest { + + private static int typeCode(WireFormat.FieldType fieldType) { + switch (fieldType) { + case DOUBLE: + return 1; + case FLOAT: + return 2; + case INT64: + return 3; + case UINT64: + return 4; + case INT32: + return 5; + case FIXED64: + return 6; + case FIXED32: + return 7; + case BOOL: + return 8; + case STRING: + return 9; + case GROUP: + return 10; + case MESSAGE: + return 11; + case BYTES: + return 12; + case UINT32: + return 13; + case ENUM: + return 14; + case SFIXED32: + return 15; + case SFIXED64: + return 16; + case SINT32: + return 17; + case SINT64: + return 18; + } + throw new AssertionError("Unhandled field type: " + fieldType); + } + + @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().get(1)).containsExactly(42L); + assertThat(value.unknownFields().get(2)).containsExactly(100); + assertThat(value.unknownFields().get(3)).containsExactly(200L); + assertThat(value.unknownFields().get(4)).containsExactly(ByteString.copyFromUtf8("hello")); + } + + @Test + public void decodeWireEntries_emptyEntries_returnsNull() { + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(), + typeCode(WireFormat.FieldType.INT64), + "custom.Message", + /* isRepeated= */ false)) + .isNull(); + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(), + typeCode(WireFormat.FieldType.MESSAGE), + "custom.Message", + /* isRepeated= */ false)) + .isNull(); + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(), + typeCode(WireFormat.FieldType.INT64), + "custom.Message", + /* isRepeated= */ true)) + .isNull(); + } + + @Test + public void decodeWireEntries_nonRepeated_lastOneWins() { + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(10L, 20L, 30L), + typeCode(WireFormat.FieldType.INT64), + "custom.Message", + /* isRepeated= */ false); + + assertThat(decoded).isEqualTo(30L); + } + + @Test + public void decodeWireEntries_repeatedUnpacked() { + Object decoded = + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(10L, 20L, 30L), + typeCode(WireFormat.FieldType.INT64), + "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())), + typeCode(WireFormat.FieldType.INT32), + "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())), + typeCode(WireFormat.FieldType.INT64), + "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())), + typeCode(WireFormat.FieldType.UINT32), + "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())), + typeCode(WireFormat.FieldType.UINT64), + "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())), + typeCode(WireFormat.FieldType.SINT32), + "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())), + typeCode(WireFormat.FieldType.SINT64), + "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)), + typeCode(WireFormat.FieldType.FIXED32), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(UnsignedLong.fromLongBits(10L))); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray()).substring(4, 12)), + typeCode(WireFormat.FieldType.FIXED64), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(UnsignedLong.fromLongBits(20L))); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray()).substring(12, 16)), + typeCode(WireFormat.FieldType.SFIXED32), + "custom.Message", + /* isRepeated= */ true)) + .isEqualTo(ImmutableList.of(-30L)); + + assertThat( + RawProtoMessageLiteValue.decodeWireEntries( + ImmutableList.of(ByteString.copyFrom(baos.toByteArray()).substring(16, 24)), + typeCode(WireFormat.FieldType.SFIXED64), + "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())), + typeCode(WireFormat.FieldType.BOOL), + "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())), + typeCode(WireFormat.FieldType.FLOAT), + "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())), + typeCode(WireFormat.FieldType.DOUBLE), + "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())), + typeCode(WireFormat.FieldType.ENUM), + "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(StandardCharsets.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/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/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java b/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java index c066bb18e..a6ff0bfc0 100644 --- a/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java +++ b/protobuf/src/main/java/dev/cel/protobuf/CelLiteDescriptor.java @@ -84,6 +84,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)); } @@ -204,6 +208,10 @@ public enum Type { SINT64 } + public int getFieldNumber() { + return fieldNumber; + } + public String getFieldName() { return fieldName; } 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..d89b7d5d5 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,13 @@ 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", "//runtime:evaluation_exception", "//runtime/planner:program_planner", "//runtime/standard:standard_function", @@ -1013,9 +1017,12 @@ 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", "//runtime:evaluation_exception", "//runtime/planner:program_planner_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..6188b0e82 --- /dev/null +++ b/runtime/src/main/java/dev/cel/runtime/LiteAttributeStep.java @@ -0,0 +1,390 @@ +// 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 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, + /* typeCode= */ 11, + /* 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, + /* typeCode= */ 11, + /* 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..fef1d688a 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", 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..fa92cbf61 --- /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()).isTrue(); + + 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()).isTrue(); + + 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()).isTrue(); + + 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()).isTrue(); + + 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(); + } +}