diff --git a/README.md b/README.md index fee5f965..2e61ff56 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The CEL specification can be found [here](https://github.com/google/cel-spec). - [Motivation](#motivation) - [Arbitrary Java classes](#arbitrary-java-classes) - [Unsigned 64-bit `uint`](#unsigned-64-bit-uint) + - [Protobuf enum semantics](#protobuf-enum-semantics) - [Native image and package verification](#native-image-and-package-verification) - [Not yet implemented](#not-yet-implemented) - [Unclear double-to-int rounding behavior](#unclear-double-to-int-rounding-behavior) @@ -394,6 +395,24 @@ but `123u == 123u` and `123 == 123` are. If you have a `uint32` or `uint64` in protobuf objects, or use `uint`s in CEL expressions, wrap those values with `org.projectnessie.cel.common.ULong`. +### Protobuf enum semantics + +CEL-Java follows the CEL-Spec v0.25.2 language definition for protobuf enum values: protobuf enum +constants and enum fields are represented as CEL `int` values. + +The upstream CEL-Spec conformance testdata also contains strong-enum cases where enum values +preserve their enum type. CEL-Java does not currently enable those strong-enum conformance cases. +They are not part of the v0.25.2 language-definition baseline and are mutually incompatible with the +legacy enum-as-int conformance sections in a single-mode runtime. + +Use numeric enum values directly, or use `int(...)` when writing expressions that should remain clear +if strong enum support is added in the future: + +```cel +TestAllTypes.NestedEnum.BAR == 1 +int(TestAllTypes.NestedEnum.BAR) == 1 +``` + ### Native image and package verification Native-image and package behavior must be verified in the consuming application's exact build. diff --git a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java index b71ec3f1..f4bebc55 100644 --- a/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java +++ b/conformance/src/test/java/org/projectnessie/cel/conformance/SimpleConformanceTest.java @@ -29,6 +29,7 @@ import static org.projectnessie.cel.EnvOption.clearMacros; import static org.projectnessie.cel.EnvOption.container; import static org.projectnessie.cel.EnvOption.declarations; +import static org.projectnessie.cel.EnvOption.macros; import static org.projectnessie.cel.EnvOption.types; import static org.projectnessie.cel.Library.StdLib; import static org.projectnessie.cel.common.types.BoolT.True; @@ -43,6 +44,12 @@ import static org.projectnessie.cel.common.types.UintT.uintOf; import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; +import static org.projectnessie.cel.extension.EncodersLib.encoders; +import static org.projectnessie.cel.extension.MathLib.math; +import static org.projectnessie.cel.extension.NetworkLib.network; +import static org.projectnessie.cel.extension.OptionalLib.optionals; +import static org.projectnessie.cel.extension.ProtoLib.proto; +import static org.projectnessie.cel.extension.StringsLib.strings; import com.google.api.expr.v1alpha1.CheckedExpr; import com.google.api.expr.v1alpha1.Decl; @@ -99,6 +106,7 @@ import org.projectnessie.cel.common.types.ref.Val; import org.projectnessie.cel.common.types.traits.Lister; import org.projectnessie.cel.common.types.traits.Mapper; +import org.projectnessie.cel.parser.Macro; class SimpleConformanceTest { @@ -109,9 +117,12 @@ class SimpleConformanceTest { private static final List TEST_FILES = List.of( "basic.textproto", + "bindings_ext.textproto", + "block_ext.textproto", "comparisons.textproto", "conversions.textproto", "dynamic.textproto", + "encoders_ext.textproto", "enums.textproto", "fields.textproto", "fp_math.textproto", @@ -119,65 +130,66 @@ class SimpleConformanceTest { "lists.textproto", "logic.textproto", "macros.textproto", + "macros2.textproto", + "math_ext.textproto", "namespace.textproto", + "network_ext.textproto", "parse.textproto", "plumbing.textproto", "proto2.textproto", + "proto2_ext.textproto", "proto3.textproto", "string.textproto", + "string_ext.textproto", "timestamps.textproto", + "type_deduction.textproto", "unknowns.textproto", "wrappers.textproto"); private static final Set SKIP_TESTS = SkipList.parse( - // Without the checker, verifying whether an assignment is allowed by the CEL spec is - // difficult, especially from a map to a struct. The checker catches this case, while the - // evaluator currently converts int(1) to string("1"). - "dynamic/struct/field_assign_proto2_bad", - "dynamic/struct/field_assign_proto3_bad", - // The test expects -0.0d, but in Java -0.0d == 0.0d, so -0.0d is evaluated as not set. - "dynamic/float/field_assign_proto3_round_to_zero", - // Malicious too-deep protobuf structure. - "parse/nest/message_literal", - // Proto equality specialties do not seem to be in effect for Java. - "comparisons/eq_wrapper/eq_proto_nan_equal", - "comparisons/ne_literal/ne_proto_nan_not_equal", - // TODO Actual known issue: protobuf Any returned by this test is wrapped twice. - "dynamic/any/var", - // New CEL-Spec v0.25.2 expectations that need follow-up parser/runtime changes. - "conversions/bool/string_1,string_t,string_0,string_f,string_true_badcase,string_false_badcase", - "fields/quoted_map_fields/field_access_slash,field_access_dash,field_access_dot,has_field_slash,has_field_dash,has_field_dot", - "namespace/namespace_shadowing/comprehension_shadowing,comprehension_shadowing_disambiguation,comprehension_shadowing_parse_only,comprehension_shadowing_selector,comprehension_shadowing_selector_parse_only,comprehension_shadowing_namespaced_selector,comprehension_shadowing_namespaced_selector_parse_only,comprehension_shadowing_nesting", - "proto2/set_null/single_message,single_duration,single_timestamp,repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", - "proto2/quoted_fields/set_field_with_quoted_name,get_field_with_quoted_name", - "proto2/extensions_has/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", - "proto2/extensions_get/package_scoped_int32,package_scoped_nested_ext,package_scoped_test_all_types_ext,package_scoped_test_all_types_nested_enum_ext,package_scoped_repeated_test_all_types,message_scoped_int64,message_scoped_nested_ext,message_scoped_nested_enum_ext,message_scoped_repeated_test_all_types", - "proto3/set_null/single_message,single_duration,single_timestamp,repeated_field_timestamp_null_pruned,repeated_field_duration_null_pruned,repeated_field_wrapper_null_pruned,map_timestamp_null_pruned,map_duration_null_pruned,map_wrapper_null_pruned,map_anytype_null_retained,single_scalar,repeated,map,list_value,single_struct", - "proto3/quoted_fields/set_field,get_field", - "timestamps/timestamp_conversions/type_comparison", - "timestamps/duration_conversions/type_comparison", - "wrappers/bool/to_null", - "wrappers/int32/to_null", - "wrappers/int64/to_null", - "wrappers/uint32/to_null", - "wrappers/uint64/to_null", - "wrappers/float/to_null", - "wrappers/double/to_null", - "wrappers/bytes/to_null", - "wrappers/string/to_null", - "dynamic/int32/field_assign_proto2_range,field_assign_proto3_range", - "dynamic/uint32/field_assign_proto2_range,field_assign_proto3_range", - "dynamic/float/field_assign_proto2_range,field_assign_proto3_range", - "enums/legacy_proto2/assign_standalone_int_too_big,assign_standalone_int_too_neg", - "enums/legacy_proto3/assign_standalone_int_too_big,assign_standalone_int_too_neg", - "enums/strong_proto2", - "enums/strong_proto3", - "fields/qualified_identifier_resolution/map_key_float", - "wrappers/uint64/to_json_string", - "wrappers/field_mask/to_json", - "wrappers/timestamp/to_json", - "wrappers/empty/to_json"); + // Strong enum semantics require typed enum values rather than treating enum literals as + // ints. + "enums/strong_proto2/literal_global", + "enums/strong_proto2/literal_nested", + "enums/strong_proto2/literal_zero", + "enums/strong_proto2/type_global", + "enums/strong_proto2/type_nested", + "enums/strong_proto2/select_default", + "enums/strong_proto2/field_type", + "enums/strong_proto2/assign_standalone_int", + "enums/strong_proto2/convert_int_inrange", + "enums/strong_proto2/convert_int_big", + "enums/strong_proto2/convert_int_neg", + "enums/strong_proto2/convert_int_too_big", + "enums/strong_proto2/convert_int_too_neg", + "enums/strong_proto2/convert_string", + "enums/strong_proto2/convert_string_bad", + "enums/strong_proto3/literal_global", + "enums/strong_proto3/literal_nested", + "enums/strong_proto3/literal_zero", + "enums/strong_proto3/type_global", + "enums/strong_proto3/type_nested", + "enums/strong_proto3/select_default", + "enums/strong_proto3/select", + "enums/strong_proto3/select_big", + "enums/strong_proto3/select_neg", + "enums/strong_proto3/field_type", + "enums/strong_proto3/assign_standalone_int", + "enums/strong_proto3/assign_standalone_int_big", + "enums/strong_proto3/assign_standalone_int_neg", + "enums/strong_proto3/convert_int_inrange", + "enums/strong_proto3/convert_int_big", + "enums/strong_proto3/convert_int_neg", + "enums/strong_proto3/convert_int_too_big", + "enums/strong_proto3/convert_int_too_neg", + "enums/strong_proto3/convert_string", + "enums/strong_proto3/convert_string_bad", + // Optional list/map/message syntax and runtime support is not implemented yet. + "block_ext/basic/optional_list", + "block_ext/basic/optional_map", + "block_ext/basic/optional_map_chained", + "block_ext/basic/optional_message"); private static final Set matchedSkips = new LinkedHashSet<>(); private static final AtomicInteger total = new AtomicInteger(); @@ -335,6 +347,9 @@ private static ParsedExpr parse(SimpleTest test) { if (test.getDisableMacros()) { parseOptions.add(clearMacros()); } + if (usesTestOnlyBlockMacros(test.getExpr())) { + parseOptions.add(macros(Macro.TestOnlyBlockMacros)); + } Env env = newEnv(parseOptions.toArray(new EnvOption[0])); AstIssuesTuple astIss = env.parse(sourceText); @@ -353,12 +368,8 @@ private static CheckedExpr check(SimpleTest test, ParsedExpr parsedExpr) Env env = newCustomEnv( - StdLib(), - container(test.getContainer()), - declarations(typeEnv), - types( - dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), - dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); + conformanceEnvOptions(test, StdLib(), declarations(typeEnv)) + .toArray(new EnvOption[0])); AstIssuesTuple astIss = env.check(parsedExprToAst(parsedExpr)); if (astIss.hasIssues()) { @@ -376,12 +387,7 @@ private static ExprValue evalChecked(SimpleTest test, CheckedExpr checkedExpr) { } private static ExprValue eval(SimpleTest test, Ast ast) { - Env env = - newEnv( - container(test.getContainer()), - types( - dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), - dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); + Env env = newEnv(conformanceEnvOptions(test).toArray(new EnvOption[0])); Program program = env.program(ast); Map args = new HashMap<>(); @@ -400,6 +406,69 @@ private static ExprValue eval(SimpleTest test, Ast ast) { .setError(ErrorSet.newBuilder().addErrors(Status.newBuilder().setMessage(err.toString()))) .build(); } + + private static List conformanceEnvOptions(SimpleTest test, EnvOption... options) { + List envOptions = new ArrayList<>(); + envOptions.add(container(test.getContainer())); + envOptions.add( + types( + dev.cel.expr.conformance.proto2.TestAllTypes.getDefaultInstance(), + dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage.getDefaultInstance(), + dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance())); + if (test.getExpr().startsWith("proto.hasExt(") + || test.getExpr().startsWith("proto.getExt(")) { + envOptions.add(proto()); + } + if (usesStringExtensions(test.getExpr())) { + envOptions.add(strings()); + } + if (test.getExpr().contains("base64.")) { + envOptions.add(encoders()); + } + if (test.getExpr().contains("math.")) { + envOptions.add(math()); + } + if (usesNetworkExtensions(test.getExpr())) { + envOptions.add(network()); + } + if (test.getExpr().contains("optional.")) { + envOptions.add(optionals()); + } + envOptions.addAll(List.of(options)); + return envOptions; + } + + private static boolean usesStringExtensions(String expression) { + return expression.contains(".charAt(") + || expression.contains(".indexOf(") + || expression.contains(".lastIndexOf(") + || expression.contains(".lowerAscii(") + || expression.contains(".upperAscii(") + || expression.contains(".replace(") + || expression.contains(".split(") + || expression.contains(".substring(") + || expression.contains(".trim(") + || expression.contains(".join(") + || expression.contains("strings.quote(") + || expression.contains(".format(") + || expression.contains(".reverse("); + } + + private static boolean usesNetworkExtensions(String expression) { + return expression.contains("ip(") + || expression.contains("cidr(") + || expression.contains("isIP(") + || expression.contains("ip.isCanonical(") + || expression.contains("net.IP") + || expression.contains("net.CIDR"); + } + + private static boolean usesTestOnlyBlockMacros(String expression) { + return expression.contains("cel.block(") + || expression.contains("cel.index(") + || expression.contains("cel.iterVar(") + || expression.contains("cel.accuVar("); + } } private static void match(String testPath, SimpleTest test, ExprValue actual) @@ -574,6 +643,8 @@ private static Val valueToRefValue(TypeAdapter adapter, Value v) { return type; } return newObjectTypeValue(typeName); + case ENUM_VALUE: + return intOf(v.getEnumValue().getValue()); default: throw new IllegalArgumentException("unknown value " + v.getKindCase()); } @@ -635,7 +706,9 @@ private static Value refValueToValue(Val res) { case Object: Message pb = (Message) res.value(); Value.Builder value = Value.newBuilder(); - if (pb instanceof ListValue) { + if (pb instanceof Any) { + value.setObjectValue(unwrapNestedAny((Any) pb)); + } else if (pb instanceof ListValue) { value.setListValue((ListValue) pb); } else if (pb instanceof MapValue) { value.setMapValue((MapValue) pb); @@ -648,6 +721,22 @@ private static Value refValueToValue(Val res) { } } + private static Any unwrapNestedAny(Any any) { + Any current = any; + while (current.is(Any.class)) { + try { + Any next = current.unpack(Any.class); + if (next.equals(current)) { + return current; + } + current = next; + } catch (InvalidProtocolBufferException e) { + return current; + } + } + return current; + } + private static T convert(Message message, Class targetType) throws InvalidProtocolBufferException { try { diff --git a/core/src/main/java/org/projectnessie/cel/checker/Checker.java b/core/src/main/java/org/projectnessie/cel/checker/Checker.java index 25737a5a..ef02447d 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Checker.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Checker.java @@ -48,8 +48,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.projectnessie.cel.checker.Types.Kind; import org.projectnessie.cel.common.Location; import org.projectnessie.cel.common.Source; @@ -228,9 +230,11 @@ void checkIdent(Expr.Builder e) { Decl ident = env.lookupIdent(identExpr.getName()); if (ident != null) { setType(e, ident.getIdent().getType()); - setReference(e, newIdentReference(ident.getName(), ident.getIdent().getValue())); + String identName = + identExpr.getName().startsWith(".") ? "." + ident.getName() : ident.getName(); + setReference(e, newIdentReference(identName, ident.getIdent().getValue())); // Overwrite the identifier with its fully qualified name. - identExpr.setName(ident.getName()); + identExpr.setName(identName); return; } @@ -242,7 +246,7 @@ void checkSelect(Expr.Builder e) { Select.Builder sel = e.getSelectExprBuilder(); // Before traversing down the tree, try to interpret as qualified name. String qname = Container.toQualifiedName(e.build()); - if (qname != null) { + if (qname != null && !isQualifiedLocalVariableSelection(sel.getOperandBuilder())) { Decl ident = env.lookupIdent(qname); if (ident != null) { if (sel.getTestOnly()) { @@ -305,6 +309,16 @@ void checkSelect(Expr.Builder e) { setType(e, resultType); } + private boolean isQualifiedLocalVariableSelection(Expr.Builder e) { + if (e.getExprKindCase() == Expr.ExprKindCase.IDENT_EXPR) { + return env.hasLocalIdent(e.getIdentExpr().getName()); + } + if (e.getExprKindCase() == Expr.ExprKindCase.SELECT_EXPR) { + return isQualifiedLocalVariableSelection(e.getSelectExprBuilder().getOperandBuilder()); + } + return false; + } + void checkCall(Expr.Builder e) { // Note: similar logic exists within the `interpreter/planner.go`. If making changes here // please consider the impact on planner.go and consolidate implementations or mirror code @@ -415,10 +429,11 @@ OverloadResolution resolveOverload( } Type overloadType = Decls.newFunctionType(overload.getResultType(), overload.getParamsList()); - if (overload.getTypeParamsCount() > 0) { + Set typeParams = collectOverloadTypeParams(overload); + if (!typeParams.isEmpty()) { // Instantiate overload's type with fresh type variables. Mapping substitutions = newMapping(); - for (String typePar : overload.getTypeParamsList()) { + for (String typePar : typeParams) { substitutions.add(Decls.newTypeParamType(typePar), newTypeVar()); } overloadType = substitute(substitutions, overloadType, false); @@ -551,14 +566,23 @@ void checkComprehension(Expr.Builder e) { Type accuType = getType(comp.getAccuInitBuilder()); Type rangeType = getType(comp.getIterRangeBuilder()); Type varType; + Type var2Type = null; switch (kindOf(rangeType)) { case kindList: - varType = rangeType.getListType().getElemType(); + if (comp.getIterVar2().isEmpty()) { + varType = rangeType.getListType().getElemType(); + } else { + varType = Decls.Int; + var2Type = rangeType.getListType().getElemType(); + } break; case kindMap: // Ranges over the keys. varType = rangeType.getMapType().getKeyType(); + if (!comp.getIterVar2().isEmpty()) { + var2Type = rangeType.getMapType().getValueType(); + } break; case kindDyn: case kindError: @@ -569,10 +593,16 @@ void checkComprehension(Expr.Builder e) { isAssignable(Decls.Dyn, rangeType); // Set the range iteration variable to type DYN as well. varType = Decls.Dyn; + if (!comp.getIterVar2().isEmpty()) { + var2Type = Decls.Dyn; + } break; default: errors.notAComprehensionRange(location(comp.getIterRangeBuilder()), rangeType); varType = Decls.Error; + if (!comp.getIterVar2().isEmpty()) { + var2Type = Decls.Error; + } break; } @@ -583,6 +613,9 @@ void checkComprehension(Expr.Builder e) { // Create a block scope for the loop. env = env.enterScope(); env.add(Decls.newVar(comp.getIterVar(), varType)); + if (!comp.getIterVar2().isEmpty()) { + env.add(Decls.newVar(comp.getIterVar2(), var2Type)); + } // Check the variable references in the condition and step. check(comp.getLoopConditionBuilder()); assertType(comp.getLoopConditionBuilder(), Decls.Bool); @@ -682,7 +715,7 @@ void setType(Expr.Builder e, Type t) { } Type getType(Expr.Builder e) { - return types.get(e.getId()); + return substitute(mappings, types.get(e.getId()), false); } void setReference(Expr.Builder e, Reference r) { @@ -716,6 +749,44 @@ static OverloadResolution newResolution(Reference checkedRef, Type t) { return new OverloadResolution(checkedRef, t); } + private static Set collectOverloadTypeParams(Overload overload) { + Set typeParams = new LinkedHashSet<>(overload.getTypeParamsList()); + overload.getParamsList().forEach(type -> collectTypeParams(type, typeParams)); + collectTypeParams(overload.getResultType(), typeParams); + return typeParams; + } + + private static void collectTypeParams(Type type, Set typeParams) { + switch (kindOf(type)) { + case kindTypeParam: + typeParams.add(type.getTypeParam()); + return; + case kindAbstract: + type.getAbstractType() + .getParameterTypesList() + .forEach(t -> collectTypeParams(t, typeParams)); + return; + case kindFunction: + type.getFunction().getArgTypesList().forEach(t -> collectTypeParams(t, typeParams)); + collectTypeParams(type.getFunction().getResultType(), typeParams); + return; + case kindList: + collectTypeParams(type.getListType().getElemType(), typeParams); + return; + case kindMap: + MapType mapType = type.getMapType(); + collectTypeParams(mapType.getKeyType(), typeParams); + collectTypeParams(mapType.getValueType(), typeParams); + return; + case kindType: + if (type.getType() != Type.getDefaultInstance()) { + collectTypeParams(type.getType(), typeParams); + } + return; + default: + } + } + Location location(Expr.Builder e) { return locationByID(e.getId()); } diff --git a/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java b/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java index 6ed4258e..cc68d810 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java +++ b/core/src/main/java/org/projectnessie/cel/checker/CheckerEnv.java @@ -36,6 +36,7 @@ import java.util.Collections; import java.util.List; import org.projectnessie.cel.common.containers.Container; +import org.projectnessie.cel.common.types.ref.TypeEnum; import org.projectnessie.cel.common.types.ref.TypeProvider; import org.projectnessie.cel.common.types.ref.Val; import org.projectnessie.cel.parser.Macro; @@ -118,6 +119,12 @@ public void add(List decls) { * such identifier is found in the Env. */ public Decl lookupIdent(String name) { + if (!name.startsWith(".") && hasLocalIdent(name)) { + Decl ident = declarations.findIdentInScope(name); + if (ident != null) { + return ident; + } + } for (String candidate : container.resolveCandidateNames(name)) { Decl ident = declarations.findIdent(candidate); if (ident != null) { @@ -146,10 +153,25 @@ public Decl lookupIdent(String name) { declarations.addIdent(decl); return decl; } + + Val identValue = provider.findIdent(candidate); + if (identValue != null && identValue.type().typeEnum() == TypeEnum.String) { + Decl decl = + Decls.newIdent( + candidate, + Decls.String, + Constant.newBuilder().setStringValue(identValue.value().toString()).build()); + declarations.addIdent(decl); + return decl; + } } return null; } + boolean hasLocalIdent(String name) { + return declarations.hasParent() && declarations.findIdentInScope(name) != null; + } + /** * LookupFunction returns a Decl proto for typeName as a function in env. Returns nil if no such * function is found in env. diff --git a/core/src/main/java/org/projectnessie/cel/checker/Scopes.java b/core/src/main/java/org/projectnessie/cel/checker/Scopes.java index 7e21e558..3a966fd9 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Scopes.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Scopes.java @@ -59,6 +59,10 @@ public Scopes pop() { return this; } + boolean hasParent() { + return parent != null; + } + /** * AddIdent adds the ident Decl in the current scope. Note: If the name collides with an existing * identifier in the scope, the Decl is overwritten. diff --git a/core/src/main/java/org/projectnessie/cel/checker/Types.java b/core/src/main/java/org/projectnessie/cel/checker/Types.java index 1032310e..682837a6 100644 --- a/core/src/main/java/org/projectnessie/cel/checker/Types.java +++ b/core/src/main/java/org/projectnessie/cel/checker/Types.java @@ -337,6 +337,9 @@ static boolean internalIsAssignable(Mapping m, Type t1, Type t2) { if (isDynOrError(t1) || isDynOrError(t2)) { return true; } + if (kind2 == Kind.kindNull) { + return internalIsAssignableNull(t1); + } // Test for when the types do not need to agree, but are more specific than dyn. switch (kind1) { @@ -491,6 +494,8 @@ static Kind kindOf(Type t) { return Kind.kindWrapper; case NULL: return Kind.kindNull; + case ABSTRACT_TYPE: + return Kind.kindAbstract; case TYPE: return Kind.kindType; case LIST_TYPE: @@ -507,6 +512,24 @@ static Kind kindOf(Type t) { /** mostGeneral returns the more general of two types which are known to unify. */ static Type mostGeneral(Type t1, Type t2) { + Kind kind1 = kindOf(t1); + Kind kind2 = kindOf(t2); + if (kind1 == Kind.kindNull && internalIsAssignableNull(t2)) { + return t2; + } + if (kind2 == Kind.kindNull && internalIsAssignableNull(t1)) { + return t1; + } + if (kind1 == Kind.kindPrimitive && kind2 == Kind.kindWrapper) { + if (t1.getPrimitive() == t2.getWrapper()) { + return t2; + } + } + if (kind1 == Kind.kindWrapper && kind2 == Kind.kindPrimitive) { + if (t1.getWrapper() == t2.getPrimitive()) { + return t1; + } + } if (isEqualOrLessSpecific(t1, t2)) { return t1; } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/IntT.java b/core/src/main/java/org/projectnessie/cel/common/types/IntT.java index 17c2baf9..c098531f 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/IntT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/IntT.java @@ -139,6 +139,9 @@ public T convertToNative(Class typeDesc) { return (T) Int64Value.of(i); } if (typeDesc == Int32Value.class) { + if (i < Integer.MIN_VALUE || i > Integer.MAX_VALUE) { + Err.throwErrorAsIllegalStateException(rangeError(i, "Java int")); + } return (T) Int32Value.of((int) i); } if (typeDesc == Val.class || typeDesc == IntT.class) { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/MapT.java b/core/src/main/java/org/projectnessie/cel/common/types/MapT.java index e15b7c43..788c0bff 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/MapT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/MapT.java @@ -20,7 +20,6 @@ import static org.projectnessie.cel.common.types.Err.isError; import static org.projectnessie.cel.common.types.Err.newErr; import static org.projectnessie.cel.common.types.Err.newTypeConversionError; -import static org.projectnessie.cel.common.types.StringT.StringType; import static org.projectnessie.cel.common.types.TypeT.TypeType; import static org.projectnessie.cel.common.types.Types.boolOf; @@ -87,6 +86,18 @@ public static Val newMaybeWrappedMap(TypeAdapter adapter, Map value) { return newWrappedMap(adapter, newMap); } + public static boolean isSupportedLiteralKeyType(Val key) { + switch (key.type().typeEnum()) { + case Bool: + case Int: + case String: + case Uint: + return true; + default: + return false; + } + } + @Override public Type type() { return MapType; @@ -136,9 +147,12 @@ private Value toPbValue() { private Struct toPbStruct() { Struct.Builder struct = Struct.newBuilder(); map.forEach( - (k, v) -> - struct.putFields( - k.convertToType(StringType).value().toString(), v.convertToNative(Value.class))); + (k, v) -> { + if (k.type().typeEnum() != TypeEnum.String) { + throw new IllegalArgumentException("bad key type"); + } + struct.putFields(k.value().toString(), v.convertToNative(Value.class)); + }); return struct.build(); } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/StringT.java b/core/src/main/java/org/projectnessie/cel/common/types/StringT.java index db308eb4..bc214752 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/StringT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/StringT.java @@ -128,11 +128,19 @@ public Val convertToType(Type typeVal) { case Double: return doubleOf(Double.parseDouble(s)); case Bool: - if ("true".equalsIgnoreCase(s)) { - return True; - } - if ("false".equalsIgnoreCase(s)) { - return False; + switch (s) { + case "1": + case "t": + case "true": + case "TRUE": + case "True": + return True; + case "0": + case "f": + case "false": + case "FALSE": + case "False": + return False; } break; case Bytes: diff --git a/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java b/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java index b99caac0..e853b092 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/TimestampT.java @@ -31,7 +31,6 @@ import static org.projectnessie.cel.common.types.Types.boolOf; import com.google.protobuf.Any; -import com.google.protobuf.StringValue; import com.google.protobuf.Timestamp; import com.google.protobuf.Value; import java.time.DateTimeException; @@ -249,7 +248,8 @@ public T convertToNative(Class typeDesc) { } if (typeDesc == Value.class) { // CEL follows the proto3 to JSON conversion which formats as an RFC 3339 encoded JSON string. - return (T) StringValue.of(jsonFormatter.format(t)); + DateTimeFormatter df = (t.getNano() > 0L) ? rfc3339nanoFormatter : rfc3339formatter; + return (T) Value.newBuilder().setStringValue(df.format(t)).build(); } throw new RuntimeException( @@ -283,22 +283,6 @@ public Val convertToType(Type typeValue) { return newTypeConversionError(TimestampType, typeValue); } - private static final DateTimeFormatter jsonFormatter = - new DateTimeFormatterBuilder() - .appendValue(ChronoField.YEAR, 4, 5, SignStyle.EXCEEDS_PAD) - .appendLiteral('-') - .appendValue(ChronoField.MONTH_OF_YEAR, 2) - .appendLiteral('-') - .appendValue(ChronoField.DAY_OF_MONTH, 2) - .appendLiteral('T') - .appendValue(ChronoField.HOUR_OF_DAY, 2) - .appendLiteral(':') - .appendValue(ChronoField.MINUTE_OF_HOUR, 2) - .appendLiteral(':') - .appendValue(ChronoField.SECOND_OF_MINUTE, 2) - .appendLiteral('Z') - .toFormatter(); - /** Only used for format a string, never for parsing. */ private static final DateTimeFormatter rfc3339formatter = new DateTimeFormatterBuilder() diff --git a/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java b/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java index 56ddacef..5806974c 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/TypeT.java @@ -55,11 +55,20 @@ public static Type newObjectTypeValue(String name) { return new ObjectTypeT(name); } + /** NewObjectTypeValue returns a *TypeValue with the supplied traits for a qualified type name. */ + public static Type newObjectTypeValue(String name, Trait... traits) { + return new ObjectTypeT(name, traits); + } + static final class ObjectTypeT extends TypeT { private final String typeName; ObjectTypeT(String typeName) { - super(TypeEnum.Object, Trait.FieldTesterType, Trait.IndexerType); + this(typeName, Trait.FieldTesterType, Trait.IndexerType); + } + + ObjectTypeT(String typeName, Trait... traits) { + super(TypeEnum.Object, traits); this.typeName = typeName; } @@ -154,11 +163,11 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (o == null || getClass() != o.getClass()) { + if (!(o instanceof Type)) { return false; } Type typeValue = (Type) o; - return typeEnum == typeValue.typeEnum() && typeName().equals(typeValue.typeName()); + return typeName().equals(typeValue.typeName()); } @Override diff --git a/core/src/main/java/org/projectnessie/cel/common/types/UintT.java b/core/src/main/java/org/projectnessie/cel/common/types/UintT.java index f0fe8b7b..1c229ea3 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/UintT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/UintT.java @@ -120,13 +120,16 @@ public T convertToNative(Class typeDesc) { return (T) UInt64Value.of(i); } if (typeDesc == UInt32Value.class) { + if (Long.compareUnsigned(i, 0xffffffffL) > 0) { + Err.throwErrorAsIllegalStateException(rangeError(Long.toUnsignedString(i), "uint32")); + } return (T) UInt32Value.of((int) i); } if (typeDesc == Val.class || typeDesc == UintT.class) { return (T) this; } if (typeDesc == Value.class) { - if (i <= maxIntJSON) { + if (Long.compareUnsigned(i, maxIntJSON) <= 0) { // JSON can accurately represent 32-bit uints as floating point values. return (T) Value.newBuilder().setNumberValue(i).build(); } else { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java index 04b5b232..0a0f3dee 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/Checked.java @@ -92,6 +92,8 @@ public final class Checked { // Well-known types. CheckedWellKnowns.put("google.protobuf.Any", checkedAny); CheckedWellKnowns.put("google.protobuf.Duration", checkedDuration); + CheckedWellKnowns.put( + "google.protobuf.FieldMask", checkedMessageType("google.protobuf.FieldMask")); CheckedWellKnowns.put("google.protobuf.Timestamp", checkedTimestamp); // Json types. CheckedWellKnowns.put("google.protobuf.ListValue", checkedListDyn); diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java index ced0befa..2fb01465 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/Db.java @@ -24,6 +24,8 @@ import com.google.protobuf.Descriptors.FileDescriptor; import com.google.protobuf.Duration; import com.google.protobuf.Empty; +import com.google.protobuf.ExtensionRegistry; +import com.google.protobuf.FieldMask; import com.google.protobuf.Message; import com.google.protobuf.Timestamp; import com.google.protobuf.Value; @@ -49,6 +51,8 @@ public final class Db { /** files contains the deduped set of FileDescriptions whose types are contained in the pb.Db. */ private final List files; + private volatile ExtensionRegistry extensionRegistry; + /** DefaultDb used at evaluation time or unless overridden at check time. */ public static final Db defaultDb = new Db(new HashMap<>(), new ArrayList<>()); @@ -62,6 +66,7 @@ public final class Db { defaultDb.registerMessage(Any.getDefaultInstance()); defaultDb.registerMessage(Duration.getDefaultInstance()); defaultDb.registerMessage(Empty.getDefaultInstance()); + defaultDb.registerMessage(FieldMask.getDefaultInstance()); defaultDb.registerMessage(Timestamp.getDefaultInstance()); defaultDb.registerMessage(Value.getDefaultInstance()); defaultDb.registerMessage(BoolValue.getDefaultInstance()); @@ -121,10 +126,14 @@ public FileDescription registerDescriptor(FileDescriptor fileDesc) { for (String enumValName : fd.getEnumNames()) { revFileDescriptorMap.put(enumValName, fd); } + for (String extensionName : fd.getExtensionNames()) { + revFileDescriptorMap.put(extensionName, fd); + } for (String msgTypeName : fd.getTypeNames()) { revFileDescriptorMap.put(msgTypeName, fd); } revFileDescriptorMap.put(path, fd); + extensionRegistry = null; // Return the specific file descriptor registered. files.add(fd); @@ -169,6 +178,47 @@ public PbTypeDescription describeType(String typeName) { return fd != null ? fd.getTypeDescription(typeName) : null; } + public FieldDescription describeExtension(String messageType, String extensionName) { + extensionName = sanitizeProtoName(extensionName); + FieldDescription extension = describeExtension(extensionName); + if (extension == null + || !sanitizeProtoName(messageType) + .equals(extension.descriptor().getContainingType().getFullName())) { + return null; + } + return extension; + } + + public FieldDescription describeExtension(String extensionName) { + extensionName = sanitizeProtoName(extensionName); + FileDescription fd = revFileDescriptorMap.get(extensionName); + return fd != null ? fd.getExtensionDescription(extensionName) : null; + } + + ExtensionRegistry extensionRegistry() { + ExtensionRegistry registry = extensionRegistry; + if (registry != null) { + return registry; + } + synchronized (this) { + registry = extensionRegistry; + if (registry == null) { + registry = ExtensionRegistry.newInstance(); + for (FileDescription file : files) { + for (FieldDescription extension : file.getExtensionDescriptions()) { + if (extension.isMessage()) { + registry.add(extension.descriptor(), extension.zero()); + } else { + registry.add(extension.descriptor()); + } + } + } + extensionRegistry = registry; + } + return registry; + } + } + /** * CollectFileDescriptorSet builds a file descriptor set associated with the file where the input * message is declared. diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java index 9eb6ea86..d5f6be7b 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/FieldDescription.java @@ -211,16 +211,8 @@ public FieldDescriptor descriptor() { public boolean isSet(Object target) { if (target instanceof Message) { Message v = (Message) target; - // pbRef = v.ProtoReflect() - Descriptor pbDesc = v.getDescriptorForType(); - if (pbDesc == desc.getContainingType()) { - // When the target protobuf shares the same message descriptor instance as the field - // descriptor, use the cached field descriptor value. - return FieldDescription.hasValueForField(desc, v); - } - // Otherwise, fallback to a dynamic lookup of the field descriptor from the target - // instance as an attempt to use the cached field descriptor will result in a panic. - return FieldDescription.hasValueForField(pbDesc.findFieldByName(name()), v); + FieldDescriptor fd = fieldDescriptorFor(v); + return fd != null && FieldDescription.hasValueForField(fd, v); } return false; } @@ -242,20 +234,8 @@ public Object getFrom(Db db, Object target) { } Message v = (Message) target; // pbRef = v.protoReflect(); - Descriptor pbDesc = v.getDescriptorForType(); - Object fieldVal; - - FieldDescriptor fd; - if (pbDesc == desc.getContainingType()) { - // When the target protobuf shares the same message descriptor instance as the field - // descriptor, use the cached field descriptor value. - fd = desc; - } else { - // Otherwise, fallback to a dynamic lookup of the field descriptor from the target - // instance as an attempt to use the cached field descriptor will result in a panic. - fd = pbDesc.findFieldByName(name()); - } - fieldVal = getValueFromField(fd, v); + FieldDescriptor fd = fieldDescriptorFor(v); + Object fieldVal = getValueFromField(fd, v); Class fieldType = fieldVal.getClass(); if (fd.getJavaType() != JavaType.MESSAGE @@ -447,7 +427,12 @@ public int hashCode() { } public boolean hasField(Object target) { - return hasValueForField(desc, (Message) target); + if (!(target instanceof Message)) { + return false; + } + Message message = (Message) target; + FieldDescriptor fd = fieldDescriptorFor(message); + return fd != null && hasValueForField(fd, message); } public Object getField(Object target) { @@ -466,7 +451,7 @@ public Object getField(Object target, TypeAdapter adapter) { public static Object getValueFromField(FieldDescriptor desc, Message message) { - if (isWellKnownType(desc) && !message.hasField(desc)) { + if (!desc.isRepeated() && isWellKnownType(desc) && !message.hasField(desc)) { return NullValue.NULL_VALUE; } @@ -541,7 +526,18 @@ private FieldDescriptor fieldDescriptorFor(Message message) { if (messageDesc == desc.getContainingType()) { return desc; } - return messageDesc.findFieldByName(name()); + if (!desc.isExtension()) { + return messageDesc.findFieldByName(name()); + } + for (FieldDescriptor field : message.getAllFields().keySet()) { + if (field.getFullName().equals(desc.getFullName())) { + return field; + } + } + if (messageDesc.getFullName().equals(desc.getContainingType().getFullName())) { + return desc; + } + return null; } private static final class UnsignedLongList extends AbstractList { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java index 3e554c1c..c9d229d9 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/FileDescription.java @@ -18,6 +18,7 @@ import com.google.protobuf.Descriptors.Descriptor; import com.google.protobuf.Descriptors.EnumDescriptor; import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Descriptors.FileDescriptor; import java.util.HashMap; import java.util.List; @@ -29,11 +30,15 @@ public final class FileDescription { private final Map types; private final Map enums; + private final Map extensions; private FileDescription( - Map types, Map enums) { + Map types, + Map enums, + Map extensions) { this.types = types; this.enums = enums; + this.extensions = extensions; } @Override @@ -45,12 +50,14 @@ public boolean equals(Object o) { return false; } FileDescription that = (FileDescription) o; - return Objects.equals(types, that.types) && Objects.equals(enums, that.enums); + return Objects.equals(types, that.types) + && Objects.equals(enums, that.enums) + && Objects.equals(extensions, that.extensions); } @Override public int hashCode() { - return Objects.hash(types, enums); + return Objects.hash(types, enums, extensions); } /** @@ -66,7 +73,10 @@ public static FileDescription newFileDescription(FileDescriptor fileDesc) { Map types = new HashMap<>(); metadata.msgTypes.forEach( (name, msgType) -> types.put(name, PbTypeDescription.newTypeDescription(name, msgType))); - return new FileDescription(types, enums); + Map extensions = new HashMap<>(); + metadata.extensions.forEach( + (name, extension) -> extensions.put(name, FieldDescription.newFieldDescription(extension))); + return new FileDescription(types, enums, extensions); } /** @@ -82,6 +92,21 @@ public String[] getEnumNames() { return enums.keySet().toArray(new String[0]); } + /** GetExtensionDescription returns a field description for a qualified extension name. */ + public FieldDescription getExtensionDescription(String extensionName) { + return extensions.get(sanitizeProtoName(extensionName)); + } + + /** GetExtensionNames returns the string names of all extensions in the file. */ + public String[] getExtensionNames() { + return extensions.keySet().toArray(new String[0]); + } + + /** GetExtensionDescriptions returns all extension field descriptions in the file. */ + public Iterable getExtensionDescriptions() { + return extensions.values(); + } + /** * GetTypeDescription returns a TypeDescription for a qualified protobuf message type name * declared within the .proto file. @@ -111,12 +136,18 @@ static final class FileMetadata { /** enumValues maps from fully-qualified enum value to enum value descriptor. */ final Map enumValues; + /** extensions maps from fully-qualified extension name to field descriptor. */ + final Map extensions; + // TODO: support enum type definitions for use in future type-check enhancements. private FileMetadata( - Map msgTypes, Map enumValues) { + Map msgTypes, + Map enumValues, + Map extensions) { this.msgTypes = msgTypes; this.enumValues = enumValues; + this.extensions = extensions; } /** @@ -126,10 +157,12 @@ private FileMetadata( static FileMetadata collectFileMetadata(FileDescriptor fileDesc) { Map msgTypes = new HashMap<>(); Map enumValues = new HashMap<>(); + Map extensions = new HashMap<>(); - collectMsgTypes(fileDesc.getMessageTypes(), msgTypes, enumValues); + collectMsgTypes(fileDesc.getMessageTypes(), msgTypes, enumValues, extensions); collectEnumValues(fileDesc.getEnumTypes(), enumValues); - return new FileMetadata(msgTypes, enumValues); + collectExtensions(fileDesc.getExtensions(), extensions); + return new FileMetadata(msgTypes, enumValues, extensions); } /** @@ -139,17 +172,26 @@ static FileMetadata collectFileMetadata(FileDescriptor fileDesc) { private static void collectMsgTypes( List msgTypes, Map msgTypeMap, - Map enumValueMap) { + Map enumValueMap, + Map extensionMap) { for (Descriptor msgType : msgTypes) { msgTypeMap.put(msgType.getFullName(), msgType); List nestedMsgTypes = msgType.getNestedTypes(); if (!nestedMsgTypes.isEmpty()) { - collectMsgTypes(nestedMsgTypes, msgTypeMap, enumValueMap); + collectMsgTypes(nestedMsgTypes, msgTypeMap, enumValueMap, extensionMap); } List nestedEnumTypes = msgType.getEnumTypes(); if (!nestedEnumTypes.isEmpty()) { collectEnumValues(nestedEnumTypes, enumValueMap); } + collectExtensions(msgType.getExtensions(), extensionMap); + } + } + + private static void collectExtensions( + List extensions, Map extensionMap) { + for (FieldDescriptor extension : extensions) { + extensionMap.put(extension.getFullName(), extension); } } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java index d139b618..cb8c8ced 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbObjectT.java @@ -21,8 +21,14 @@ import static org.projectnessie.cel.common.types.Types.boolOf; import com.google.protobuf.Any; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor.JavaType; import com.google.protobuf.DynamicMessage; +import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.Message; +import com.google.protobuf.Struct; +import com.google.protobuf.Timestamp; import com.google.protobuf.Value; import org.projectnessie.cel.common.types.ObjectT; import org.projectnessie.cel.common.types.StringT; @@ -57,7 +63,7 @@ public Val isSet(Val field) { return noSuchOverload(this, "isSet", field); } String protoFieldStr = (String) field.value(); - FieldDescription fd = typeDesc().fieldByName(protoFieldStr); + FieldDescription fd = fieldDescription(protoFieldStr); if (fd == null) { return noSuchField(protoFieldStr); } @@ -70,13 +76,29 @@ public Val get(Val index) { return noSuchOverload(this, "get", index); } String protoFieldStr = (String) index.value(); - FieldDescription fd = typeDesc().fieldByName(protoFieldStr); + FieldDescription fd = fieldDescription(protoFieldStr); if (fd == null) { return noSuchField(protoFieldStr); } return nativeToValue(fd.getField(value, adapter)); } + @Override + public Val equal(Val other) { + if (!(other instanceof PbObjectT)) { + return super.equal(other); + } + + PbObjectT otherObject = (PbObjectT) other; + if (!typeDesc().name().equals(otherObject.typeDesc().name())) { + return boolOf(false); + } + if (containsNaN(message()) || containsNaN(otherObject.message())) { + return boolOf(false); + } + return boolOf(message().equals(otherObject.message())); + } + @SuppressWarnings("unchecked") @Override public T convertToNative(Class typeDesc) { @@ -102,20 +124,15 @@ public T convertToNative(Class typeDesc) { } if (typeDesc == Value.class) { // jsonValueType - throw new UnsupportedOperationException("IMPLEMENT proto-to-json"); - // TODO proto-to-json - // // Marshal the proto to JSON first, and then rehydrate as protobuf.Value as there is no - // // support for direct conversion from proto.Message to protobuf.Value. - // bytes, err := protojson.Marshal(pb) - // if err != nil { - // return nil, err - // } - // json := &structpb.Value{} - // err = protojson.Unmarshal(bytes, json) - // if err != nil { - // return nil, err - // } - // return json, nil + if (value instanceof Empty) { + return (T) Value.newBuilder().setStructValue(Struct.getDefaultInstance()).build(); + } + if (value instanceof FieldMask) { + return (T) Value.newBuilder().setStringValue(fieldMaskJsonValue((FieldMask) value)).build(); + } + if (value instanceof Timestamp) { + return adapter.nativeToValue(value).convertToNative(typeDesc); + } } if (typeDesc.isAssignableFrom(this.typeDesc.reflectType()) || typeDesc == Object.class) { if (value instanceof Any || value instanceof DynamicMessage) { @@ -141,10 +158,73 @@ private Message message() { return (Message) value; } + private static boolean containsNaN(Message message) { + for (FieldDescriptor field : message.getAllFields().keySet()) { + Object fieldValue = message.getField(field); + if (field.isRepeated()) { + for (Object element : (Iterable) fieldValue) { + if (containsNaN(field, element)) { + return true; + } + } + } else if (containsNaN(field, fieldValue)) { + return true; + } + } + return false; + } + + private static boolean containsNaN(FieldDescriptor field, Object value) { + JavaType javaType = field.getJavaType(); + if (javaType == JavaType.DOUBLE) { + return Double.isNaN((Double) value); + } + if (javaType == JavaType.FLOAT) { + return Float.isNaN((Float) value); + } + return javaType == JavaType.MESSAGE && containsNaN((Message) value); + } + + private static String fieldMaskJsonValue(FieldMask fieldMask) { + StringBuilder value = new StringBuilder(); + for (String path : fieldMask.getPathsList()) { + if (value.length() > 0) { + value.append(','); + } + value.append(fieldMaskPathJsonValue(path)); + } + return value.toString(); + } + + private static String fieldMaskPathJsonValue(String path) { + StringBuilder value = new StringBuilder(path.length()); + boolean upperNext = false; + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '_') { + upperNext = true; + } else if (upperNext) { + value.append(Character.toUpperCase(c)); + upperNext = false; + } else { + value.append(c); + } + } + return value.toString(); + } + private PbTypeDescription typeDesc() { return (PbTypeDescription) typeDesc; } + private FieldDescription fieldDescription(String fieldName) { + FieldDescription field = typeDesc().fieldByName(fieldName); + if (field != null || !(adapter instanceof ProtoTypeRegistry)) { + return field; + } + return ((ProtoTypeRegistry) adapter).findFieldDescription(typeDesc().name(), fieldName); + } + @SuppressWarnings("unchecked") private T buildFrom(Class typeDesc) { try { diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java index fd1fc2d4..782b6ec2 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/PbTypeDescription.java @@ -138,17 +138,19 @@ public Object maybeUnwrap(Db db, Object m) { return anyWithEmptyType(); } PbTypeDescription realTypeDescriptor = db.describeType(realTypeName); - Message realMsg = realTypeDescriptor.zeroMsg.getParserForType().parseFrom(realValue); + Message realMsg = + DynamicMessage.parseFrom( + realTypeDescriptor.getDescriptor(), realValue, db.extensionRegistry()); return realTypeDescriptor.maybeUnwrap(db, realMsg); } if (!(zeroMsg instanceof DynamicMessage)) { if (msg instanceof Any) { Any any = (Any) msg; - msg = zeroMsg.getParserForType().parseFrom(any.getValue()); - } else if (msg instanceof DynamicMessage) { + msg = DynamicMessage.parseFrom(getDescriptor(), any.getValue(), db.extensionRegistry()); + } else if (msg instanceof DynamicMessage && !hasExtensions(msg)) { DynamicMessage dyn = (DynamicMessage) msg; - msg = zeroMsg.getParserForType().parseFrom(dyn.toByteString()); + msg = zeroMsg.getParserForType().parseFrom(dyn.toByteString(), db.extensionRegistry()); } } } catch (InvalidProtocolBufferException e) { @@ -275,6 +277,15 @@ static Object unwrap(Db db, Description desc, Message msg) { return msg; } + private static boolean hasExtensions(Message message) { + for (FieldDescriptor field : message.getAllFields().keySet()) { + if (field.isExtension()) { + return true; + } + } + return false; + } + private static java.time.Duration asJavaDuration(Duration d) { return java.time.Duration.ofSeconds(d.getSeconds(), d.getNanos()); } diff --git a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java index 18aa9fe1..dd567ead 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/pb/ProtoTypeRegistry.java @@ -30,6 +30,7 @@ import static org.projectnessie.cel.common.types.MapT.MapType; import static org.projectnessie.cel.common.types.NullT.NullType; import static org.projectnessie.cel.common.types.StringT.StringType; +import static org.projectnessie.cel.common.types.StringT.stringOf; import static org.projectnessie.cel.common.types.TimestampT.TimestampType; import static org.projectnessie.cel.common.types.TypeT.TypeType; import static org.projectnessie.cel.common.types.TypeT.newObjectTypeValue; @@ -54,6 +55,7 @@ import com.google.protobuf.DoubleValue; import com.google.protobuf.Duration; import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.FloatValue; import com.google.protobuf.Int32Value; import com.google.protobuf.Int64Value; @@ -79,10 +81,12 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.projectnessie.cel.common.types.NullT; import org.projectnessie.cel.common.types.TypeT; import org.projectnessie.cel.common.types.ref.FieldType; import org.projectnessie.cel.common.types.ref.TypeRegistry; import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Lister; public final class ProtoTypeRegistry implements TypeRegistry { private static final ProtoTypeRegistry DEFAULT_REGISTRY = newDefaultRegistry(); @@ -132,6 +136,7 @@ private static ProtoTypeRegistry newDefaultRegistry() { Arrays.asList( DoubleValue.getDescriptor().getFile(), Empty.getDescriptor().getFile(), + FieldMask.getDescriptor().getFile(), Timestamp.getDescriptor().getFile(), UInt64Value.getDescriptor().getFile(), Any.getDescriptor().getFile(), @@ -205,16 +210,30 @@ public FieldType findFieldType(String messageType, String fieldName) { } private FieldType loadFieldType(String messageType, String fieldName) { + FieldDescription field = findFieldDescription(messageType, fieldName); + if (field == null) { + return null; + } + FieldDescription resolvedField = field; + return new FieldType( + resolvedField.checkedType(), + resolvedField::hasField, + target -> resolvedField.getField(target, this)); + } + + FieldDescription findFieldDescription(String messageType, String fieldName) { PbTypeDescription msgType = pbdb.describeType(messageType); if (msgType == null) { return null; } FieldDescription field = msgType.fieldByName(fieldName); + if (field == null) { + field = pbdb.describeExtension(messageType, fieldName); + } if (field == null) { return null; } - return new FieldType( - field.checkedType(), field::hasField, target -> field.getField(target, this)); + return field; } @Override @@ -227,6 +246,9 @@ public Val findIdent(String identName) { if (enumVal != null) { return intOf(enumVal.value()); } + if (pbdb.describeExtension(identName) != null) { + return stringOf(identName); + } return null; } @@ -268,24 +290,114 @@ private Val newValueSetFields(Map fields, PbTypeDescription td, Bui // TODO resolve inefficiency for maps: first converted from a MapT to a native Java map and // then to a protobuf struct. The intermediate step (the Java map) could be omitted. - Object value = nv.getValue().convertToNative(field.reflectType()); - if (value.getClass().isArray()) { - value = Arrays.asList((Object[]) value); + FieldDescriptor pbDesc = field.descriptor(); + if (nv.getValue() == org.projectnessie.cel.common.types.NullT.NullValue + && isNullClearedField(pbDesc)) { + continue; } - FieldDescriptor pbDesc = field.descriptor(); + try { + Object value = toNativeFieldValue(nv.getValue(), field); + if (value.getClass().isArray()) { + value = Arrays.asList((Object[]) value); + } + + if (pbDesc.getJavaType() == JavaType.ENUM) { + value = intToProtoEnumValues(field, value); + } - if (pbDesc.getJavaType() == JavaType.ENUM) { - value = intToProtoEnumValues(field, value); + if (pbDesc.isMapField()) { + value = toProtoMapStructure(pbDesc, value); + } + + builder.setField(pbDesc, value); + } catch (RuntimeException e) { + return newErr(e, "invalid value for field '%s': %s", name, e.getMessage()); } + } + return null; + } - if (pbDesc.isMapField()) { - value = toProtoMapStructure(pbDesc, value); + private Object toNativeFieldValue(Val value, FieldDescription field) { + FieldDescriptor fieldDesc = field.descriptor(); + if (fieldDesc.isRepeated() + && !fieldDesc.isMapField() + && isNullPrunedMessageField(fieldDesc) + && value instanceof Lister) { + return toNativeRepeatedFieldValue((Lister) value, fieldDesc); + } + return value.convertToNative(field.reflectType()); + } + + private Object toNativeRepeatedFieldValue(Lister value, FieldDescriptor fieldDesc) { + Class elementType = messageNativeType(fieldDesc); + int size = (int) value.size().intValue(); + List converted = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + Val element = value.get(intOf(i)); + if (element == NullT.NullValue && isNullPrunedMessageField(fieldDesc)) { + continue; } + converted.add(element.convertToNative(elementType)); + } + return converted; + } - builder.setField(pbDesc, value); + private static boolean isNullClearedField(FieldDescriptor field) { + if (field.getJavaType() != JavaType.MESSAGE || field.isRepeated() || field.isMapField()) { + return false; + } + Type wellKnownType = Checked.CheckedWellKnowns.get(field.getMessageType().getFullName()); + return wellKnownType == null || isNullPrunedMessageField(field); + } + + private static boolean isNullPrunedMessageField(FieldDescriptor field) { + if (field.getJavaType() != JavaType.MESSAGE) { + return false; + } + String typeName = field.getMessageType().getFullName(); + Type wellKnownType = Checked.CheckedWellKnowns.get(typeName); + if (wellKnownType == null) { + return false; + } + return wellKnownType.hasWrapper() + || typeName.equals("google.protobuf.Duration") + || typeName.equals("google.protobuf.Timestamp"); + } + + private static Class messageNativeType(FieldDescriptor field) { + switch (field.getMessageType().getFullName()) { + case "google.protobuf.Any": + return Any.class; + case "google.protobuf.BoolValue": + return BoolValue.class; + case "google.protobuf.BytesValue": + return BytesValue.class; + case "google.protobuf.DoubleValue": + return DoubleValue.class; + case "google.protobuf.Duration": + return Duration.class; + case "google.protobuf.FieldMask": + return FieldMask.class; + case "google.protobuf.FloatValue": + return FloatValue.class; + case "google.protobuf.Int32Value": + return Int32Value.class; + case "google.protobuf.Int64Value": + return Int64Value.class; + case "google.protobuf.StringValue": + return StringValue.class; + case "google.protobuf.Timestamp": + return Timestamp.class; + case "google.protobuf.UInt32Value": + return UInt32Value.class; + case "google.protobuf.UInt64Value": + return UInt64Value.class; + case "google.protobuf.Value": + return Value.class; + default: + return Message.class; } - return null; } /** @@ -309,8 +421,13 @@ private Object toProtoMapStructure(FieldDescriptor fieldDesc, Object value) { // if (!(k instanceof String)) { // return Err.newTypeConversionError(k.getClass().getName(), String.class.getName()); // } - if (valueFieldType == WireFormat.FieldType.MESSAGE && !(v instanceof Message)) { - v = nativeToValue(v).convertToNative(Value.class); + if (valueFieldType == WireFormat.FieldType.MESSAGE) { + if (isNullNativeValue(v) && isNullPrunedMessageField(valueType)) { + continue; + } + if (!(v instanceof Message)) { + v = nativeToValue(v).convertToNative(messageNativeType(valueType)); + } } MapEntry newEntry = @@ -323,6 +440,10 @@ private Object toProtoMapStructure(FieldDescriptor fieldDesc, Object value) { return value; } + private static boolean isNullNativeValue(Object value) { + return value == null || value == com.google.protobuf.NullValue.NULL_VALUE; + } + /** * Converts a value of type {@link Number} to {@link EnumValueDescriptor}, also works for arrays * and {@link List}s containing {@link Number}s. diff --git a/core/src/main/java/org/projectnessie/cel/extension/EncodersLib.java b/core/src/main/java/org/projectnessie/cel/extension/EncodersLib.java new file mode 100644 index 00000000..2f3972aa --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/EncodersLib.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static java.util.Collections.singletonList; +import static org.projectnessie.cel.common.types.BytesT.bytesOf; +import static org.projectnessie.cel.common.types.Err.newErr; +import static org.projectnessie.cel.common.types.Err.noSuchOverload; +import static org.projectnessie.cel.common.types.StringT.stringOf; + +import java.util.Base64; +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.BytesT; +import org.projectnessie.cel.common.types.StringT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** EncodersLib provides CEL helper functions for common binary-to-text encodings. */ +public final class EncodersLib implements Library { + private static final String BASE64_ENCODE = "base64.encode"; + private static final String BASE64_DECODE = "base64.decode"; + private static final String BASE64_ENCODE_OVERLOAD = "base64_encode_bytes"; + private static final String BASE64_DECODE_OVERLOAD = "base64_decode_string"; + + private EncodersLib() {} + + public static EnvOption encoders() { + return Library.Lib(new EncodersLib()); + } + + @Override + public List getCompileOptions() { + return List.of( + EnvOption.declarations( + Decls.newFunction( + BASE64_ENCODE, + Decls.newOverload( + BASE64_ENCODE_OVERLOAD, singletonList(Decls.Bytes), Decls.String)), + Decls.newFunction( + BASE64_DECODE, + Decls.newOverload( + BASE64_DECODE_OVERLOAD, singletonList(Decls.String), Decls.Bytes)))); + } + + @Override + public List getProgramOptions() { + return List.of( + ProgramOption.functions( + Overload.unary(BASE64_ENCODE, EncodersLib::base64Encode), + Overload.unary(BASE64_ENCODE_OVERLOAD, EncodersLib::base64Encode), + Overload.unary(BASE64_DECODE, EncodersLib::base64Decode), + Overload.unary(BASE64_DECODE_OVERLOAD, EncodersLib::base64Decode))); + } + + private static Val base64Encode(Val value) { + if (!(value instanceof BytesT)) { + return noSuchOverload(value, BASE64_ENCODE, BASE64_ENCODE_OVERLOAD, new Val[] {value}); + } + byte[] bytes = value.convertToNative(byte[].class); + return stringOf(Base64.getEncoder().encodeToString(bytes)); + } + + private static Val base64Decode(Val value) { + if (!(value instanceof StringT)) { + return noSuchOverload(value, BASE64_DECODE, BASE64_DECODE_OVERLOAD, new Val[] {value}); + } + String text = value.convertToNative(String.class); + try { + return bytesOf(Base64.getDecoder().decode(text)); + } catch (IllegalArgumentException e) { + return newErr(e, "invalid base64 string"); + } + } +} diff --git a/core/src/main/java/org/projectnessie/cel/extension/MathLib.java b/core/src/main/java/org/projectnessie/cel/extension/MathLib.java new file mode 100644 index 00000000..0c801e10 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/MathLib.java @@ -0,0 +1,432 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static org.projectnessie.cel.common.types.BoolT.False; +import static org.projectnessie.cel.common.types.BoolT.True; +import static org.projectnessie.cel.common.types.DoubleT.doubleOf; +import static org.projectnessie.cel.common.types.Err.errIntOverflow; +import static org.projectnessie.cel.common.types.Err.newErr; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.UintT.uintOf; + +import com.google.api.expr.v1alpha1.Decl; +import com.google.api.expr.v1alpha1.Type; +import java.util.ArrayList; +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.DoubleT; +import org.projectnessie.cel.common.types.IntT; +import org.projectnessie.cel.common.types.UintT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Lister; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** MathLib provides CEL helper functions from the standard math extension library. */ +public final class MathLib implements Library { + private static final String GREATEST = "math.greatest"; + private static final String LEAST = "math.least"; + private static final String CEIL = "math.ceil"; + private static final String FLOOR = "math.floor"; + private static final String ROUND = "math.round"; + private static final String TRUNC = "math.trunc"; + private static final String ABS = "math.abs"; + private static final String SIGN = "math.sign"; + private static final String IS_NAN = "math.isNaN"; + private static final String IS_INF = "math.isInf"; + private static final String IS_FINITE = "math.isFinite"; + private static final String BIT_AND = "math.bitAnd"; + private static final String BIT_OR = "math.bitOr"; + private static final String BIT_XOR = "math.bitXor"; + private static final String BIT_NOT = "math.bitNot"; + private static final String BIT_SHIFT_LEFT = "math.bitShiftLeft"; + private static final String BIT_SHIFT_RIGHT = "math.bitShiftRight"; + + private MathLib() {} + + public static EnvOption math() { + return Library.Lib(new MathLib()); + } + + @Override + public List getCompileOptions() { + List declarations = new ArrayList<>(); + declarations.add(minMaxDeclaration(GREATEST)); + declarations.add(minMaxDeclaration(LEAST)); + declarations.add(unaryDeclaration(CEIL, Decls.Double)); + declarations.add(unaryDeclaration(FLOOR, Decls.Double)); + declarations.add(unaryDeclaration(ROUND, Decls.Double)); + declarations.add(unaryDeclaration(TRUNC, Decls.Double)); + declarations.add(unaryDeclaration(ABS, Decls.Dyn)); + declarations.add(unaryDeclaration(SIGN, Decls.Dyn)); + declarations.add(unaryDeclaration(IS_NAN, Decls.Bool)); + declarations.add(unaryDeclaration(IS_INF, Decls.Bool)); + declarations.add(unaryDeclaration(IS_FINITE, Decls.Bool)); + declarations.add(binaryDeclaration(BIT_AND, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_OR, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_XOR, Decls.Dyn)); + declarations.add(unaryDeclaration(BIT_NOT, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_SHIFT_LEFT, Decls.Dyn)); + declarations.add(binaryDeclaration(BIT_SHIFT_RIGHT, Decls.Dyn)); + return List.of(EnvOption.declarations(declarations)); + } + + @Override + public List getProgramOptions() { + List overloads = new ArrayList<>(); + overloads.add( + Overload.overload(GREATEST, null, MathLib::greatest, MathLib::greatest, MathLib::greatest)); + overloads.add(Overload.overload(LEAST, null, MathLib::least, MathLib::least, MathLib::least)); + addArityOverloads(overloads, GREATEST, MathLib::greatest); + addArityOverloads(overloads, LEAST, MathLib::least); + overloads.add(Overload.unary(CEIL, MathLib::ceil)); + overloads.add(Overload.unary(overloadId(CEIL, 1), MathLib::ceil)); + overloads.add(Overload.unary(FLOOR, MathLib::floor)); + overloads.add(Overload.unary(overloadId(FLOOR, 1), MathLib::floor)); + overloads.add(Overload.unary(ROUND, MathLib::round)); + overloads.add(Overload.unary(overloadId(ROUND, 1), MathLib::round)); + overloads.add(Overload.unary(TRUNC, MathLib::trunc)); + overloads.add(Overload.unary(overloadId(TRUNC, 1), MathLib::trunc)); + overloads.add(Overload.unary(ABS, MathLib::abs)); + overloads.add(Overload.unary(overloadId(ABS, 1), MathLib::abs)); + overloads.add(Overload.unary(SIGN, MathLib::sign)); + overloads.add(Overload.unary(overloadId(SIGN, 1), MathLib::sign)); + overloads.add(Overload.unary(IS_NAN, MathLib::isNaN)); + overloads.add(Overload.unary(overloadId(IS_NAN, 1), MathLib::isNaN)); + overloads.add(Overload.unary(IS_INF, MathLib::isInf)); + overloads.add(Overload.unary(overloadId(IS_INF, 1), MathLib::isInf)); + overloads.add(Overload.unary(IS_FINITE, MathLib::isFinite)); + overloads.add(Overload.unary(overloadId(IS_FINITE, 1), MathLib::isFinite)); + overloads.add(Overload.binary(BIT_AND, MathLib::bitAnd)); + overloads.add(Overload.binary(overloadId(BIT_AND, 2), MathLib::bitAnd)); + overloads.add(Overload.binary(BIT_OR, MathLib::bitOr)); + overloads.add(Overload.binary(overloadId(BIT_OR, 2), MathLib::bitOr)); + overloads.add(Overload.binary(BIT_XOR, MathLib::bitXor)); + overloads.add(Overload.binary(overloadId(BIT_XOR, 2), MathLib::bitXor)); + overloads.add(Overload.unary(BIT_NOT, MathLib::bitNot)); + overloads.add(Overload.unary(overloadId(BIT_NOT, 1), MathLib::bitNot)); + overloads.add(Overload.binary(BIT_SHIFT_LEFT, MathLib::bitShiftLeft)); + overloads.add(Overload.binary(overloadId(BIT_SHIFT_LEFT, 2), MathLib::bitShiftLeft)); + overloads.add(Overload.binary(BIT_SHIFT_RIGHT, MathLib::bitShiftRight)); + overloads.add(Overload.binary(overloadId(BIT_SHIFT_RIGHT, 2), MathLib::bitShiftRight)); + return List.of(ProgramOption.functions(overloads.toArray(Overload[]::new))); + } + + private static Decl minMaxDeclaration(String function) { + List overloads = new ArrayList<>(); + overloads.add(Decls.newOverload(overloadId(function, "int"), List.of(Decls.Int), Decls.Int)); + overloads.add(Decls.newOverload(overloadId(function, "uint"), List.of(Decls.Uint), Decls.Uint)); + overloads.add( + Decls.newOverload(overloadId(function, "double"), List.of(Decls.Double), Decls.Double)); + for (int arity = 2; arity <= 5; arity++) { + overloads.add(Decls.newOverload(overloadId(function, arity), dynArgs(arity), Decls.Dyn)); + } + overloads.add( + Decls.newOverload( + overloadId(function, "list"), List.of(Decls.newListType(Decls.Dyn)), Decls.Dyn)); + return Decls.newFunction(function, overloads); + } + + private static Decl unaryDeclaration(String function, Type result) { + return Decls.newFunction( + function, Decls.newOverload(overloadId(function, 1), List.of(Decls.Dyn), result)); + } + + private static Decl binaryDeclaration(String function, Type result) { + return Decls.newFunction( + function, Decls.newOverload(overloadId(function, 2), dynArgs(2), result)); + } + + private static List dynArgs(int count) { + List args = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + args.add(Decls.Dyn); + } + return args; + } + + private static void addArityOverloads( + List overloads, + String function, + org.projectnessie.cel.interpreter.functions.FunctionOp op) { + overloads.add(Overload.unary(overloadId(function, "int"), op::invoke)); + overloads.add(Overload.unary(overloadId(function, "uint"), op::invoke)); + overloads.add(Overload.unary(overloadId(function, "double"), op::invoke)); + overloads.add( + Overload.binary(overloadId(function, 2), (left, right) -> op.invoke(left, right))); + for (int arity = 3; arity <= 5; arity++) { + overloads.add(Overload.function(overloadId(function, arity), op)); + } + overloads.add(Overload.unary(overloadId(function, "list"), op::invoke)); + } + + private static String overloadId(String function, int arity) { + return function.replace('.', '_') + "_" + arity; + } + + private static String overloadId(String function, String suffix) { + return function.replace('.', '_') + "_" + suffix; + } + + private static Val greatest(Val... values) { + return minMax(values, true); + } + + private static Val least(Val... values) { + return minMax(values, false); + } + + private static Val minMax(Val[] values, boolean greatest) { + List candidates = candidates(values); + if (candidates.isEmpty()) { + return newErr("empty argument list"); + } + + Val result = candidates.get(0); + if (!isNumber(result)) { + return noSuchOverload(); + } + for (int i = 1; i < candidates.size(); i++) { + Val candidate = candidates.get(i); + if (!isNumber(candidate)) { + return noSuchOverload(); + } + int cmp = compareNumbers(candidate, result); + if ((greatest && cmp > 0) || (!greatest && cmp < 0)) { + result = candidate; + } + } + return result; + } + + private static List candidates(Val[] values) { + if (values.length == 1 && values[0] instanceof Lister list) { + int size = (int) list.size().intValue(); + List elements = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + elements.add(list.get(intOf(i))); + } + return elements; + } + return List.of(values); + } + + private static int compareNumbers(Val left, Val right) { + if (left instanceof DoubleT || right instanceof DoubleT) { + return Double.compare(asDouble(left), asDouble(right)); + } + if (left instanceof UintT && right instanceof UintT) { + return Long.compareUnsigned(left.intValue(), right.intValue()); + } + if (left instanceof UintT && right instanceof IntT) { + long rightValue = right.intValue(); + return rightValue < 0 ? 1 : Long.compareUnsigned(left.intValue(), rightValue); + } + if (left instanceof IntT && right instanceof UintT) { + long leftValue = left.intValue(); + return leftValue < 0 ? -1 : Long.compareUnsigned(leftValue, right.intValue()); + } + return Long.compare(left.intValue(), right.intValue()); + } + + private static double asDouble(Val value) { + if (value instanceof DoubleT) { + return value.doubleValue(); + } + if (value instanceof UintT) { + return Long.toUnsignedString(value.intValue()).equals("18446744073709551615") + ? 18446744073709551615.0 + : Double.parseDouble(Long.toUnsignedString(value.intValue())); + } + return value.intValue(); + } + + private static Val ceil(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return doubleOf(Math.ceil(value.doubleValue())); + } + + private static Val floor(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return doubleOf(Math.floor(value.doubleValue())); + } + + private static Val round(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return doubleOf(d); + } + return doubleOf(d < 0 ? Math.ceil(d - 0.5d) : Math.floor(d + 0.5d)); + } + + private static Val trunc(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return doubleOf(d); + } + return doubleOf(d < 0 ? Math.ceil(d) : Math.floor(d)); + } + + private static Val abs(Val value) { + if (value instanceof UintT) { + return value; + } + if (value instanceof IntT) { + long v = value.intValue(); + if (v == Long.MIN_VALUE) { + return errIntOverflow; + } + return intOf(Math.abs(v)); + } + if (value instanceof DoubleT) { + return doubleOf(Math.abs(value.doubleValue())); + } + return noSuchOverload(); + } + + private static Val sign(Val value) { + if (value instanceof UintT) { + return uintOf(value.intValue() == 0 ? 0 : 1); + } + if (value instanceof IntT) { + return intOf(Long.compare(value.intValue(), 0)); + } + if (value instanceof DoubleT) { + return doubleOf(Double.compare(value.doubleValue(), 0.0d)); + } + return noSuchOverload(); + } + + private static Val isNaN(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return Double.isNaN(value.doubleValue()) ? True : False; + } + + private static Val isInf(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + return Double.isInfinite(value.doubleValue()) ? True : False; + } + + private static Val isFinite(Val value) { + if (!(value instanceof DoubleT)) { + return noSuchOverload(); + } + double d = value.doubleValue(); + return !Double.isNaN(d) && !Double.isInfinite(d) ? True : False; + } + + private static Val bitAnd(Val left, Val right) { + return bitwise(left, right, (a, b) -> a & b); + } + + private static Val bitOr(Val left, Val right) { + return bitwise(left, right, (a, b) -> a | b); + } + + private static Val bitXor(Val left, Val right) { + return bitwise(left, right, (a, b) -> a ^ b); + } + + private static Val bitwise(Val left, Val right, LongOperator op) { + if (left instanceof IntT && right instanceof IntT) { + return intOf(op.apply(left.intValue(), right.intValue())); + } + if (left instanceof UintT && right instanceof UintT) { + return uintOf(op.apply(left.intValue(), right.intValue())); + } + return noSuchOverload(); + } + + private static Val bitNot(Val value) { + if (value instanceof IntT) { + return intOf(~value.intValue()); + } + if (value instanceof UintT) { + return uintOf(~value.intValue()); + } + return noSuchOverload(); + } + + private static Val bitShiftLeft(Val left, Val right) { + if (!(right instanceof IntT)) { + return noSuchOverload(); + } + long shift = right.intValue(); + if (shift < 0) { + return newErr("negative offset"); + } + if (shift >= Long.SIZE) { + return left instanceof UintT ? uintOf(0) : left instanceof IntT ? intOf(0) : noSuchOverload(); + } + if (left instanceof IntT) { + return intOf(left.intValue() << shift); + } + if (left instanceof UintT) { + return uintOf(left.intValue() << shift); + } + return noSuchOverload(); + } + + private static Val bitShiftRight(Val left, Val right) { + if (!(right instanceof IntT)) { + return noSuchOverload(); + } + long shift = right.intValue(); + if (shift < 0) { + return newErr("negative offset"); + } + if (shift >= Long.SIZE) { + return left instanceof UintT ? uintOf(0) : left instanceof IntT ? intOf(0) : noSuchOverload(); + } + if (left instanceof IntT) { + return intOf(left.intValue() >>> shift); + } + if (left instanceof UintT) { + return uintOf(left.intValue() >>> shift); + } + return noSuchOverload(); + } + + private static boolean isNumber(Val value) { + return value instanceof IntT || value instanceof UintT || value instanceof DoubleT; + } + + private static Val noSuchOverload() { + return newErr("no such overload"); + } + + @FunctionalInterface + private interface LongOperator { + long apply(long left, long right); + } +} diff --git a/core/src/main/java/org/projectnessie/cel/extension/NetworkLib.java b/core/src/main/java/org/projectnessie/cel/extension/NetworkLib.java new file mode 100644 index 00000000..25ba2fe4 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/NetworkLib.java @@ -0,0 +1,547 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static java.util.Collections.singletonList; +import static org.projectnessie.cel.common.types.BoolT.False; +import static org.projectnessie.cel.common.types.BoolT.True; +import static org.projectnessie.cel.common.types.Err.newErr; +import static org.projectnessie.cel.common.types.Err.newTypeConversionError; +import static org.projectnessie.cel.common.types.Err.noSuchOverload; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.StringT.StringType; +import static org.projectnessie.cel.common.types.StringT.stringOf; +import static org.projectnessie.cel.common.types.TypeT.newObjectTypeValue; +import static org.projectnessie.cel.common.types.Types.boolOf; + +import com.google.api.expr.v1alpha1.Type; +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.ref.BaseVal; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Receiver; +import org.projectnessie.cel.common.types.traits.Trait; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** NetworkLib provides CEL helper functions from the standard network extension library. */ +public final class NetworkLib implements Library { + private static final String IP = "ip"; + private static final String CIDR = "cidr"; + private static final String IS_IP = "isIP"; + private static final String IP_IS_CANONICAL = "ip.isCanonical"; + private static final String NET_IP = "net.IP"; + private static final String NET_CIDR = "net.CIDR"; + + private static final Type IP_TYPE = Decls.newObjectType(NET_IP); + private static final Type CIDR_TYPE = Decls.newObjectType(NET_CIDR); + + private static final org.projectnessie.cel.common.types.ref.Type IP_TYPE_VALUE = + newObjectTypeValue(NET_IP, Trait.ReceiverType); + private static final org.projectnessie.cel.common.types.ref.Type CIDR_TYPE_VALUE = + newObjectTypeValue(NET_CIDR, Trait.ReceiverType); + + private NetworkLib() {} + + public static EnvOption network() { + return Library.Lib(new NetworkLib()); + } + + @Override + public List getCompileOptions() { + return List.of( + EnvOption.types(IP_TYPE_VALUE, CIDR_TYPE_VALUE), + EnvOption.declarations( + Decls.newVar(NET_IP, Decls.newTypeType(IP_TYPE)), + Decls.newVar(NET_CIDR, Decls.newTypeType(CIDR_TYPE)), + Decls.newFunction( + IP, Decls.newOverload("ip_string", singletonList(Decls.String), IP_TYPE)), + Decls.newFunction( + CIDR, Decls.newOverload("cidr_string", singletonList(Decls.String), CIDR_TYPE)), + Decls.newFunction( + IS_IP, + Decls.newOverload("is_ip_string", singletonList(Decls.String), Decls.Bool), + Decls.newOverload("is_ip_cidr", singletonList(CIDR_TYPE), Decls.Bool)), + Decls.newFunction( + IP_IS_CANONICAL, + Decls.newOverload( + "ip_is_canonical_string", singletonList(Decls.String), Decls.Bool)), + Decls.newFunction( + "string", + Decls.newOverload("string_ip", singletonList(IP_TYPE), Decls.String), + Decls.newOverload("string_cidr", singletonList(CIDR_TYPE), Decls.String)), + Decls.newFunction( + "family", + Decls.newInstanceOverload("ip_family", singletonList(IP_TYPE), Decls.Int)), + Decls.newFunction( + "isUnspecified", + Decls.newInstanceOverload("ip_is_unspecified", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isLoopback", + Decls.newInstanceOverload("ip_is_loopback", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isGlobalUnicast", + Decls.newInstanceOverload( + "ip_is_global_unicast", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isLinkLocalMulticast", + Decls.newInstanceOverload( + "ip_is_link_local_multicast", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "isLinkLocalUnicast", + Decls.newInstanceOverload( + "ip_is_link_local_unicast", singletonList(IP_TYPE), Decls.Bool)), + Decls.newFunction( + "containsIP", + Decls.newInstanceOverload( + "cidr_contains_ip", List.of(CIDR_TYPE, IP_TYPE), Decls.Bool), + Decls.newInstanceOverload( + "cidr_contains_ip_string", List.of(CIDR_TYPE, Decls.String), Decls.Bool)), + Decls.newFunction( + "containsCIDR", + Decls.newInstanceOverload( + "cidr_contains_cidr", List.of(CIDR_TYPE, CIDR_TYPE), Decls.Bool), + Decls.newInstanceOverload( + "cidr_contains_cidr_string", List.of(CIDR_TYPE, Decls.String), Decls.Bool)), + Decls.newFunction( + "ip", Decls.newInstanceOverload("cidr_ip", singletonList(CIDR_TYPE), IP_TYPE)), + Decls.newFunction( + "masked", + Decls.newInstanceOverload("cidr_masked", singletonList(CIDR_TYPE), CIDR_TYPE)), + Decls.newFunction( + "prefixLength", + Decls.newInstanceOverload( + "cidr_prefix_length", singletonList(CIDR_TYPE), Decls.Int)))); + } + + @Override + public List getProgramOptions() { + return List.of( + ProgramOption.functions( + Overload.unary(IP, NetworkLib::ip), + Overload.unary("ip_string", NetworkLib::ip), + Overload.unary(CIDR, NetworkLib::cidr), + Overload.unary("cidr_string", NetworkLib::cidr), + Overload.unary(IS_IP, NetworkLib::isIp), + Overload.unary("is_ip_string", NetworkLib::isIp), + Overload.unary("is_ip_cidr", value -> newErr("no such overload")), + Overload.unary(IP_IS_CANONICAL, NetworkLib::ipIsCanonical), + Overload.unary("ip_is_canonical_string", NetworkLib::ipIsCanonical), + Overload.unary("string_ip", value -> value.convertToType(StringType)), + Overload.unary("string_cidr", value -> value.convertToType(StringType)), + Overload.unary("cidr_ip", value -> ((CidrT) value).receive("ip", "cidr_ip"))), + ProgramOption.globals(Map.of(NET_IP, IP_TYPE_VALUE, NET_CIDR, CIDR_TYPE_VALUE))); + } + + private static Val ip(Val value) { + if (value instanceof CidrT cidr) { + return cidr.ip; + } + if (!isString(value)) { + return noSuchOverload(null, IP, value); + } + return parseIp(value.value().toString()); + } + + private static Val cidr(Val value) { + if (!isString(value)) { + return noSuchOverload(null, CIDR, value); + } + return parseCidr(value.value().toString()); + } + + private static Val isIp(Val value) { + if (!isString(value)) { + return noSuchOverload(null, IS_IP, value); + } + return parseIp(value.value().toString()) instanceof IpT ? True : False; + } + + private static Val ipIsCanonical(Val value) { + if (!isString(value)) { + return noSuchOverload(null, IP_IS_CANONICAL, value); + } + Val parsed = parseIp(value.value().toString()); + if (!(parsed instanceof IpT ip)) { + return parsed; + } + return boolOf(value.value().toString().equals(ip.canonical)); + } + + private static boolean isString(Val value) { + return value.type().typeEnum() == org.projectnessie.cel.common.types.ref.TypeEnum.String; + } + + private static Val parseIp(String text) { + if (text.contains("%")) { + return newErr("IP Address with zone value is not allowed"); + } + if (text.indexOf(':') >= 0 && text.indexOf('.') >= 0) { + return newErr("IPv4-mapped IPv6 address is not allowed"); + } + try { + if (text.indexOf(':') >= 0) { + InetAddress address = InetAddress.getByName(text); + byte[] bytes = address.getAddress(); + if (bytes.length == 4) { + return new IpT(bytes, 4); + } + if (bytes.length == 16) { + return new IpT(bytes, 6); + } + } else { + return new IpT(parseIpv4(text), 4); + } + } catch (IllegalArgumentException | UnknownHostException e) { + // fall through + } + return newErr("IP Address '%s' parse error during conversion from string", text); + } + + private static byte[] parseIpv4(String text) { + String[] parts = text.split("\\.", -1); + if (parts.length != 4) { + throw new IllegalArgumentException("invalid IPv4 address"); + } + byte[] bytes = new byte[4]; + for (int i = 0; i < 4; i++) { + String part = parts[i]; + if (part.isEmpty() || (part.length() > 1 && part.charAt(0) == '0')) { + throw new IllegalArgumentException("invalid IPv4 address"); + } + int value = Integer.parseInt(part); + if (value < 0 || value > 255) { + throw new IllegalArgumentException("invalid IPv4 address"); + } + bytes[i] = (byte) value; + } + return bytes; + } + + private static Val parseCidr(String text) { + int slash = text.indexOf('/'); + if (slash < 0 || slash != text.lastIndexOf('/') || slash == text.length() - 1) { + return newErr("network address parse error during conversion from string"); + } + String ipText = text.substring(0, slash); + String prefixText = text.substring(slash + 1); + if (ipText.contains("%")) { + return newErr("CIDR with zone value is not allowed"); + } + Val parsedIp = parseIp(ipText); + if (!(parsedIp instanceof IpT ip)) { + return parsedIp; + } + try { + int prefix = Integer.parseInt(prefixText); + int bits = ip.family == 4 ? 32 : 128; + if (prefix < 0 || prefix > bits) { + return newErr("network address parse error during conversion from string"); + } + return new CidrT(ip, prefix); + } catch (NumberFormatException e) { + return newErr("network address parse error during conversion from string"); + } + } + + private static BigInteger unsigned(byte[] bytes) { + return new BigInteger(1, bytes); + } + + private static byte[] bytes(BigInteger value, int length) { + byte[] source = value.toByteArray(); + byte[] target = new byte[length]; + int copy = Math.min(source.length, length); + System.arraycopy(source, source.length - copy, target, length - copy, copy); + return target; + } + + private static byte[] mask(byte[] address, int prefix) { + int bits = address.length * Byte.SIZE; + if (prefix == bits) { + return address.clone(); + } + BigInteger value = unsigned(address); + BigInteger mask = BigInteger.ONE.shiftLeft(bits).subtract(BigInteger.ONE); + mask = mask.xor(BigInteger.ONE.shiftLeft(bits - prefix).subtract(BigInteger.ONE)); + return bytes(value.and(mask), address.length); + } + + private static String canonicalIpv4(byte[] bytes) { + return (bytes[0] & 0xff) + + "." + + (bytes[1] & 0xff) + + "." + + (bytes[2] & 0xff) + + "." + + (bytes[3] & 0xff); + } + + private static String canonicalIpv6(byte[] bytes) { + int[] words = new int[8]; + for (int i = 0; i < words.length; i++) { + words[i] = ((bytes[i * 2] & 0xff) << 8) | (bytes[i * 2 + 1] & 0xff); + } + + int bestStart = -1; + int bestLength = 0; + for (int i = 0; i < words.length; ) { + if (words[i] != 0) { + i++; + continue; + } + int start = i; + while (i < words.length && words[i] == 0) { + i++; + } + int length = i - start; + if (length > bestLength && length > 1) { + bestStart = start; + bestLength = length; + } + } + + StringBuilder result = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + if (i == bestStart) { + if (!result.isEmpty() && result.charAt(result.length() - 1) != ':') { + result.append(':'); + } + result.append(':'); + i += bestLength - 1; + continue; + } + if (!result.isEmpty() && result.charAt(result.length() - 1) != ':') { + result.append(':'); + } + result.append(Integer.toHexString(words[i])); + } + return result.toString(); + } + + private static final class IpT extends BaseVal implements Receiver { + private final byte[] bytes; + private final int family; + private final String canonical; + + private IpT(byte[] bytes, int family) { + this.bytes = bytes.clone(); + this.family = family; + this.canonical = family == 4 ? canonicalIpv4(bytes) : canonicalIpv6(bytes); + } + + @Override + @SuppressWarnings("unchecked") + public T convertToNative(Class typeDesc) { + if (typeDesc == String.class || typeDesc == Object.class) { + return (T) canonical; + } + if (typeDesc == byte[].class) { + return (T) bytes.clone(); + } + throw new IllegalArgumentException( + String.format("Unsupported conversion of '%s' to '%s'", NET_IP, typeDesc.getName())); + } + + @Override + public Val convertToType(org.projectnessie.cel.common.types.ref.Type typeVal) { + if (typeVal == StringType) { + return stringOf(canonical); + } + if (typeVal.typeName().equals(org.projectnessie.cel.common.types.TypeT.TypeType.typeName())) { + return IP_TYPE_VALUE; + } + if (typeVal.typeName().equals(NET_IP)) { + return this; + } + return newTypeConversionError(NET_IP, typeVal); + } + + @Override + public Val equal(Val other) { + if (!(other instanceof IpT otherIp)) { + return False; + } + return boolOf(Arrays.equals(bytes, otherIp.bytes)); + } + + @Override + public Val receive(String function, String overload, Val... args) { + if (args.length != 0) { + return noSuchOverload(this, function, overload, args); + } + switch (function) { + case "family": + return intOf(family); + case "isUnspecified": + return boolOf(unsigned(bytes).signum() == 0); + case "isLoopback": + return boolOf( + family == 4 ? (bytes[0] & 0xff) == 127 : unsigned(bytes).equals(BigInteger.ONE)); + case "isGlobalUnicast": + return boolOf(!isMulticast() && unsigned(bytes).signum() != 0 && !isBroadcast()); + case "isLinkLocalMulticast": + return boolOf( + family == 4 + ? canonical.startsWith("224.0.0.") + : (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0x02); + case "isLinkLocalUnicast": + return boolOf( + family == 4 + ? (bytes[0] & 0xff) == 169 && (bytes[1] & 0xff) == 254 + : (bytes[0] & 0xff) == 0xfe && ((bytes[1] & 0xc0) == 0x80)); + default: + return noSuchOverload(this, function, overload, args); + } + } + + private boolean isMulticast() { + return family == 4 ? (bytes[0] & 0xf0) == 0xe0 : (bytes[0] & 0xff) == 0xff; + } + + private boolean isBroadcast() { + return family == 4 + && unsigned(bytes).equals(BigInteger.ONE.shiftLeft(32).subtract(BigInteger.ONE)); + } + + @Override + public org.projectnessie.cel.common.types.ref.Type type() { + return IP_TYPE_VALUE; + } + + @Override + public Object value() { + return canonical; + } + } + + private static final class CidrT extends BaseVal implements Receiver { + private final IpT ip; + private final int prefix; + private final String canonical; + + private CidrT(IpT ip, int prefix) { + this.ip = ip; + this.prefix = prefix; + this.canonical = ip.canonical + "/" + prefix; + } + + @Override + @SuppressWarnings("unchecked") + public T convertToNative(Class typeDesc) { + if (typeDesc == String.class || typeDesc == Object.class) { + return (T) canonical; + } + throw new IllegalArgumentException( + String.format("Unsupported conversion of '%s' to '%s'", NET_CIDR, typeDesc.getName())); + } + + @Override + public Val convertToType(org.projectnessie.cel.common.types.ref.Type typeVal) { + if (typeVal == StringType) { + return stringOf(canonical); + } + if (typeVal.typeName().equals(org.projectnessie.cel.common.types.TypeT.TypeType.typeName())) { + return CIDR_TYPE_VALUE; + } + if (typeVal.typeName().equals(NET_CIDR)) { + return this; + } + return newTypeConversionError(NET_CIDR, typeVal); + } + + @Override + public Val equal(Val other) { + if (!(other instanceof CidrT otherCidr)) { + return False; + } + return boolOf(prefix == otherCidr.prefix && ip.equal(otherCidr.ip) == True); + } + + @Override + public Val receive(String function, String overload, Val... args) { + switch (function) { + case "containsIP": + return containsIp(args); + case "containsCIDR": + return containsCidr(args); + case "ip": + return args.length == 0 ? ip : noSuchOverload(this, function, overload, args); + case "masked": + return args.length == 0 ? masked() : noSuchOverload(this, function, overload, args); + case "prefixLength": + return args.length == 0 ? intOf(prefix) : noSuchOverload(this, function, overload, args); + default: + return noSuchOverload(this, function, overload, args); + } + } + + private Val containsIp(Val[] args) { + if (args.length != 1) { + return noSuchOverload(this, "containsIP", "", args); + } + Val candidate = + args[0] instanceof IpT + ? args[0] + : isString(args[0]) ? parseIp(args[0].value().toString()) : null; + if (!(candidate instanceof IpT candidateIp)) { + return candidate != null ? candidate : noSuchOverload(this, "containsIP", "", args); + } + if (candidateIp.family != ip.family) { + return False; + } + return boolOf(Arrays.equals(mask(candidateIp.bytes, prefix), mask(ip.bytes, prefix))); + } + + private Val containsCidr(Val[] args) { + if (args.length != 1) { + return noSuchOverload(this, "containsCIDR", "", args); + } + Val candidate = + args[0] instanceof CidrT + ? args[0] + : isString(args[0]) ? parseCidr(args[0].value().toString()) : null; + if (!(candidate instanceof CidrT candidateCidr)) { + return candidate != null ? candidate : noSuchOverload(this, "containsCIDR", "", args); + } + if (candidateCidr.ip.family != ip.family || candidateCidr.prefix < prefix) { + return False; + } + return boolOf(Arrays.equals(mask(candidateCidr.ip.bytes, prefix), mask(ip.bytes, prefix))); + } + + private CidrT masked() { + return new CidrT(new IpT(mask(ip.bytes, prefix), ip.family), prefix); + } + + @Override + public org.projectnessie.cel.common.types.ref.Type type() { + return CIDR_TYPE_VALUE; + } + + @Override + public Object value() { + return canonical; + } + } +} diff --git a/core/src/main/java/org/projectnessie/cel/extension/OptionalLib.java b/core/src/main/java/org/projectnessie/cel/extension/OptionalLib.java new file mode 100644 index 00000000..0fb18242 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/OptionalLib.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; + +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; + +/** + * OptionalLib provides compile-time declarations for CEL optional helper functions. + * + *

The current implementation intentionally exposes type-checking support only. It is sufficient + * for check-only conformance cases that exercise optional type deduction, but it does not provide + * runtime optional values or optional-selection semantics. + */ +public final class OptionalLib implements Library { + private static final String OPTIONAL_TYPE = "optional_type"; + private static final String OPTIONAL_NONE = "optional.none"; + private static final String OPTIONAL_OF = "optional.of"; + private static final String OPTIONAL_OF_NON_ZERO_VALUE = "optional.ofNonZeroValue"; + private static final String TYPE_PARAM_A = "A"; + + private OptionalLib() {} + + public static EnvOption optionals() { + return Library.Lib(new OptionalLib()); + } + + @Override + public List getCompileOptions() { + var typeParamA = Decls.newTypeParamType(TYPE_PARAM_A); + var optionalA = Decls.newAbstractType(OPTIONAL_TYPE, singletonList(typeParamA)); + var typeParams = singletonList(TYPE_PARAM_A); + + return List.of( + EnvOption.declarations( + Decls.newFunction( + OPTIONAL_NONE, + Decls.newParameterizedOverload( + "optional_none", emptyList(), optionalA, typeParams)), + Decls.newFunction( + OPTIONAL_OF, + Decls.newParameterizedOverload( + "optional_of", singletonList(typeParamA), optionalA, typeParams)), + Decls.newFunction( + OPTIONAL_OF_NON_ZERO_VALUE, + Decls.newParameterizedOverload( + "optional_of_non_zero_value", + singletonList(typeParamA), + optionalA, + typeParams)))); + } + + @Override + public List getProgramOptions() { + return emptyList(); + } +} diff --git a/core/src/main/java/org/projectnessie/cel/extension/ProtoLib.java b/core/src/main/java/org/projectnessie/cel/extension/ProtoLib.java new file mode 100644 index 00000000..1fa2ebc1 --- /dev/null +++ b/core/src/main/java/org/projectnessie/cel/extension/ProtoLib.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static java.util.Arrays.asList; +import static org.projectnessie.cel.common.types.Err.noSuchOverload; + +import java.util.List; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.Library; +import org.projectnessie.cel.ProgramOption; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.StringT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.FieldTester; +import org.projectnessie.cel.common.types.traits.Indexer; +import org.projectnessie.cel.interpreter.functions.Overload; + +/** + * ProtoLib provides CEL protobuf extension helper functions. + * + *

The extension-name argument is a fully-qualified protobuf extension field name. Expressions + * that use extension identifiers instead of string literals need those identifiers registered with + * the type provider, for example through {@link EnvOption#types(Object...)} with protobuf + * descriptors that contain the extensions. + */ +public final class ProtoLib implements Library { + private static final String HAS_EXT = "proto.hasExt"; + private static final String GET_EXT = "proto.getExt"; + private static final String HAS_EXT_OVERLOAD = "proto_has_ext"; + private static final String GET_EXT_OVERLOAD = "proto_get_ext"; + + private ProtoLib() {} + + public static EnvOption proto() { + return Library.Lib(new ProtoLib()); + } + + @Override + public List getCompileOptions() { + return List.of( + EnvOption.declarations( + Decls.newFunction( + HAS_EXT, + Decls.newOverload(HAS_EXT_OVERLOAD, asList(Decls.Dyn, Decls.String), Decls.Bool)), + Decls.newFunction( + GET_EXT, + Decls.newOverload(GET_EXT_OVERLOAD, asList(Decls.Dyn, Decls.String), Decls.Dyn)))); + } + + @Override + public List getProgramOptions() { + return List.of( + ProgramOption.functions( + Overload.binary(HAS_EXT, ProtoLib::hasExt), + Overload.binary(HAS_EXT_OVERLOAD, ProtoLib::hasExt), + Overload.binary(GET_EXT, ProtoLib::getExt), + Overload.binary(GET_EXT_OVERLOAD, ProtoLib::getExt))); + } + + private static Val hasExt(Val object, Val extensionName) { + if (!(object instanceof FieldTester) || !(extensionName instanceof StringT)) { + return noSuchOverload(object, HAS_EXT, HAS_EXT_OVERLOAD, new Val[] {object, extensionName}); + } + return ((FieldTester) object).isSet(extensionName); + } + + private static Val getExt(Val object, Val extensionName) { + if (!(object instanceof Indexer) || !(extensionName instanceof StringT)) { + return noSuchOverload(object, GET_EXT, GET_EXT_OVERLOAD, new Val[] {object, extensionName}); + } + return ((Indexer) object).get(extensionName); + } +} diff --git a/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java b/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java index 4469648c..32056c91 100644 --- a/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java +++ b/core/src/main/java/org/projectnessie/cel/extension/StringsLib.java @@ -15,6 +15,11 @@ */ package org.projectnessie.cel.extension; +import static java.math.RoundingMode.HALF_EVEN; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.projectnessie.cel.common.types.IntT.intOf; + +import java.math.BigDecimal; import java.util.*; import java.util.regex.Pattern; import org.projectnessie.cel.EnvOption; @@ -22,6 +27,10 @@ import org.projectnessie.cel.ProgramOption; import org.projectnessie.cel.checker.Decls; import org.projectnessie.cel.common.types.Err; +import org.projectnessie.cel.common.types.StringT; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.Indexer; +import org.projectnessie.cel.common.types.traits.Sizer; import org.projectnessie.cel.interpreter.functions.Overload; /** @@ -239,10 +248,13 @@ public class StringsLib implements Library { private static final String LAST_INDEX_OF = "lastIndexOf"; private static final String LOWER_ASCII = "lowerAscii"; private static final String REPLACE = "replace"; + private static final String REVERSE = "reverse"; private static final String SPLIT = "split"; private static final String SUBSTR = "substring"; private static final String TRIM_SPACE = "trim"; private static final String UPPER_ASCII = "upperAscii"; + private static final String FORMAT = "format"; + private static final String QUOTE = "strings.quote"; // whitespace characters definition from // https://en.wikipedia.org/wiki/Whitespace_character#Unicode @@ -326,6 +338,10 @@ public List getCompileOptions() { "string_replace_string_string_int", Arrays.asList(Decls.String, Decls.String, Decls.String, Decls.Int), Decls.String)), + Decls.newFunction( + REVERSE, + Decls.newInstanceOverload( + "string_reverse", Arrays.asList(Decls.String), Decls.String)), Decls.newFunction( SPLIT, Decls.newInstanceOverload( @@ -349,7 +365,16 @@ public List getCompileOptions() { Decls.newFunction( UPPER_ASCII, Decls.newInstanceOverload( - "string_upper_ascii", Arrays.asList(Decls.String), Decls.String))); + "string_upper_ascii", Arrays.asList(Decls.String), Decls.String)), + Decls.newFunction( + FORMAT, + Decls.newInstanceOverload( + "string_format", + Arrays.asList(Decls.String, Decls.newListType(Decls.Dyn)), + Decls.String)), + Decls.newFunction( + QUOTE, + Decls.newOverload("strings_quote", Arrays.asList(Decls.String), Decls.String))); return List.of(option); } @@ -363,7 +388,10 @@ public List getProgramOptions() { null, null, Guards.callInStrStrOutInt(StringsLib::indexOf), - Guards.callInStrStrIntOutInt(StringsLib::indexOfOffset)), + values -> + values.length == 3 + ? Guards.callInStrStrIntOutInt(StringsLib::indexOfOffset).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.overload( JOIN, null, @@ -375,7 +403,10 @@ public List getProgramOptions() { null, null, Guards.callInStrStrOutInt(StringsLib::lastIndexOf), - Guards.callInStrStrIntOutInt(StringsLib::lastIndexOfOffset)), + values -> + values.length == 3 + ? Guards.callInStrStrIntOutInt(StringsLib::lastIndexOfOffset).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.unary(LOWER_ASCII, Guards.callInStrOutStr(StringsLib::lowerASCII)), Overload.overload( REPLACE, @@ -391,20 +422,29 @@ public List getProgramOptions() { } return Err.maybeNoSuchOverloadErr(null); }), + Overload.unary(REVERSE, Guards.callInStrOutStr(StringsLib::reverse)), Overload.overload( SPLIT, null, null, Guards.callInStrStrOutStrArr(StringsLib::split), - Guards.callInStrStrIntOutStrArr(StringsLib::splitN)), + values -> + values.length == 3 + ? Guards.callInStrStrIntOutStrArr(StringsLib::splitN).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.overload( SUBSTR, null, null, Guards.callInStrIntOutStr(StringsLib::substr), - Guards.callInStrIntIntOutStr(StringsLib::substrRange)), + values -> + values.length == 3 + ? Guards.callInStrIntIntOutStr(StringsLib::substrRange).invoke(values) + : Err.maybeNoSuchOverloadErr(null)), Overload.unary(TRIM_SPACE, Guards.callInStrOutStr(StringsLib::trimSpace)), - Overload.unary(UPPER_ASCII, Guards.callInStrOutStr(StringsLib::upperASCII))); + Overload.unary(UPPER_ASCII, Guards.callInStrOutStr(StringsLib::upperASCII)), + Overload.binary(FORMAT, StringsLib::format), + Overload.unary(QUOTE, Guards.callInStrOutStr(StringsLib::quote))); return List.of(functions); } @@ -463,6 +503,51 @@ static String replace(String str, String old, String replacement) { return str.replace(old, replacement); } + static String reverse(String str) { + return new StringBuilder(str).reverse().toString(); + } + + static String quote(String str) { + StringBuilder quoted = new StringBuilder(str.length() + 2); + quoted.append('"'); + for (int offset = 0; offset < str.length(); ) { + int codePoint = str.codePointAt(offset); + offset += Character.charCount(codePoint); + switch (codePoint) { + case '\u0007': + quoted.append("\\a"); + break; + case '\b': + quoted.append("\\b"); + break; + case '\f': + quoted.append("\\f"); + break; + case '\n': + quoted.append("\\n"); + break; + case '\r': + quoted.append("\\r"); + break; + case '\t': + quoted.append("\\t"); + break; + case '\u000b': + quoted.append("\\v"); + break; + case '\\': + quoted.append("\\\\"); + break; + case '"': + quoted.append("\\\""); + break; + default: + quoted.appendCodePoint(codePoint); + } + } + return quoted.append('"').toString(); + } + /** * replace first n non-overlapping instance of {old} replaced by {replacement}. It works as strings.Replace in Go to have consistent behavior @@ -647,4 +732,274 @@ static String upperASCII(String str) { } return stringBuilder.toString(); } + + private static Val format(Val pattern, Val args) { + if (!(pattern instanceof StringT) || !(args instanceof Sizer) || !(args instanceof Indexer)) { + return Err.maybeNoSuchOverloadErr(null); + } + try { + return StringT.stringOf( + formatPattern((String) pattern.value(), (Sizer) args, (Indexer) args)); + } catch (FormatException e) { + return Err.newErr("%s", e.getMessage()); + } + } + + private static String formatPattern(String pattern, Sizer argsSizer, Indexer argsIndexer) { + int argCount = Math.toIntExact(argsSizer.size().intValue()); + int argIndex = 0; + StringBuilder out = new StringBuilder(pattern.length()); + for (int i = 0; i < pattern.length(); i++) { + char ch = pattern.charAt(i); + if (ch != '%') { + out.append(ch); + continue; + } + if (++i >= pattern.length()) { + throw new FormatException("could not parse formatting clause: missing formatting clause"); + } + ch = pattern.charAt(i); + if (ch == '%') { + out.append('%'); + continue; + } + int precision = -1; + if (ch == '.') { + int precisionStart = ++i; + while (i < pattern.length() && Character.isDigit(pattern.charAt(i))) { + i++; + } + if (precisionStart == i || i >= pattern.length()) { + throw new FormatException("could not parse formatting clause: malformed precision"); + } + precision = Integer.parseInt(pattern.substring(precisionStart, i)); + ch = pattern.charAt(i); + } + if ("sdboxXfe".indexOf(ch) < 0) { + throw new FormatException( + "could not parse formatting clause: unrecognized formatting clause \"%s\"", ch); + } + if (argIndex >= argCount) { + throw new FormatException("index %d out of range", argIndex); + } + Val arg = argsIndexer.get(intOf(argIndex++)); + out.append(formatValue(ch, precision, arg)); + } + return out.toString(); + } + + private static String formatValue(char clause, int precision, Val arg) { + switch (clause) { + case 's': + return renderStringClause(arg); + case 'd': + return renderDecimalClause(arg); + case 'b': + return renderBinaryClause(arg); + case 'o': + return renderOctalClause(arg); + case 'x': + case 'X': + return renderHexClause(arg, clause == 'X'); + case 'f': + return renderFixedPointClause(arg, precision >= 0 ? precision : 6); + case 'e': + return renderScientificClause(arg, precision >= 0 ? precision : 6); + default: + throw new FormatException( + "could not parse formatting clause: unrecognized formatting clause \"%s\"", clause); + } + } + + private static String renderStringClause(Val value) { + switch (value.type().typeEnum()) { + case String: + return value.value().toString(); + case Bool: + return Boolean.toString(value.booleanValue()); + case Bytes: + return new String(value.convertToNative(byte[].class), UTF_8); + case Int: + return Long.toString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue()); + case Double: + return renderDouble(value.doubleValue()); + case Null: + return "null"; + case Type: + case Duration: + case Timestamp: + return value.convertToType(StringT.StringType).value().toString(); + case List: + return renderList(value); + case Map: + return renderMap(value); + default: + throw new FormatException( + "error during formatting: string clause can only be used on strings, bools, bytes, ints, doubles, maps, lists, types, durations, and timestamps, was given %s", + value.type().typeName()); + } + } + + private static String renderDecimalClause(Val value) { + switch (value.type().typeEnum()) { + case Int: + return Long.toString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue()); + case Double: + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return renderDouble(d); + } + break; + default: + } + throw new FormatException( + "error during formatting: decimal clause can only be used on integers, was given %s", + value.type().typeName()); + } + + private static String renderBinaryClause(Val value) { + switch (value.type().typeEnum()) { + case Int: + return Long.toBinaryString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue(), 2); + case Bool: + return value.booleanValue() ? "1" : "0"; + default: + throw new FormatException( + "error during formatting: only integers and bools can be formatted as binary, was given %s", + value.type().typeName()); + } + } + + private static String renderOctalClause(Val value) { + switch (value.type().typeEnum()) { + case Int: + return Long.toOctalString(value.intValue()); + case Uint: + return Long.toUnsignedString(value.intValue(), 8); + default: + throw new FormatException( + "error during formatting: octal clause can only be used on integers, was given %s", + value.type().typeName()); + } + } + + private static String renderHexClause(Val value, boolean upperCase) { + String hex; + switch (value.type().typeEnum()) { + case Int: + hex = Long.toHexString(value.intValue()); + break; + case Uint: + hex = Long.toUnsignedString(value.intValue(), 16); + break; + case String: + hex = bytesToHex(value.value().toString().getBytes(UTF_8)); + break; + case Bytes: + hex = bytesToHex(value.convertToNative(byte[].class)); + break; + default: + throw new FormatException( + "error during formatting: only integers, byte buffers, and strings can be formatted as hex, was given %s", + value.type().typeName()); + } + return upperCase ? hex.toUpperCase(Locale.ROOT) : hex; + } + + private static String renderFixedPointClause(Val value, int precision) { + switch (value.type().typeEnum()) { + case Int: + case Uint: + case Double: + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return renderDouble(d); + } + return BigDecimal.valueOf(d).setScale(precision, HALF_EVEN).toPlainString(); + default: + throw new FormatException( + "error during formatting: fixed-point clause can only be used on doubles, was given %s", + value.type().typeName()); + } + } + + private static String renderScientificClause(Val value, int precision) { + switch (value.type().typeEnum()) { + case Int: + case Uint: + case Double: + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return renderDouble(d); + } + return String.format(Locale.ROOT, "%." + precision + "e", d); + default: + throw new FormatException( + "error during formatting: scientific clause can only be used on doubles, was given %s", + value.type().typeName()); + } + } + + private static String renderList(Val value) { + Sizer sizer = (Sizer) value; + Indexer indexer = (Indexer) value; + int size = Math.toIntExact(sizer.size().intValue()); + StringBuilder out = new StringBuilder("["); + for (int i = 0; i < size; i++) { + if (i > 0) { + out.append(", "); + } + out.append(renderStringClause(indexer.get(intOf(i)))); + } + return out.append(']').toString(); + } + + private static String renderMap(Val value) { + org.projectnessie.cel.common.types.IteratorT iterator = + ((org.projectnessie.cel.common.types.IterableT) value).iterator(); + Indexer indexer = (Indexer) value; + List entries = new ArrayList<>(); + while (iterator.hasNext().booleanValue()) { + Val key = iterator.next(); + entries.add(renderStringClause(key) + ": " + renderStringClause(indexer.get(key))); + } + Collections.sort(entries); + return "{" + String.join(", ", entries) + "}"; + } + + private static String renderDouble(double value) { + if (Double.isNaN(value)) { + return "NaN"; + } + if (value == Double.POSITIVE_INFINITY) { + return "Infinity"; + } + if (value == Double.NEGATIVE_INFINITY) { + return "-Infinity"; + } + return Double.toString(value); + } + + private static String bytesToHex(byte[] bytes) { + char[] hex = new char[bytes.length * 2]; + char[] digits = "0123456789abcdef".toCharArray(); + for (int i = 0; i < bytes.length; i++) { + int b = bytes[i] & 0xff; + hex[i * 2] = digits[b >>> 4]; + hex[i * 2 + 1] = digits[b & 0x0f]; + } + return new String(hex); + } + + private static final class FormatException extends RuntimeException { + FormatException(String message, Object... args) { + super(String.format(Locale.ROOT, message, args)); + } + } } diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java b/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java index ff0ea5e2..d68b68aa 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/Activation.java @@ -101,6 +101,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + name = name.substring(1); + } Object obj = bindings.get(name); if (obj == null) { if (!bindings.containsKey(name)) { @@ -139,6 +142,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + name = name.substring(1); + } Object result = provider.apply(name); if (result instanceof ResolvedValue) { return (ResolvedValue) result; @@ -176,6 +182,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + return parent.resolveName(name.substring(1)); + } ResolvedValue object = child.resolveName(name); if (object.present()) { return object; @@ -236,6 +245,9 @@ public Activation parent() { @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + return delegate.resolveName(name); + } return delegate.resolveName(name); } @@ -278,6 +290,9 @@ public Activation parent() { /** ResolveName implements the Activation interface method. */ @Override public ResolvedValue resolveName(String name) { + if (name.startsWith(".")) { + return parent.resolveName(name.substring(1)); + } if (name.equals(this.name)) { return ResolvedValue.resolvedValue(val); } diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java b/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java index 47f16cc9..e8b701f1 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/AstPruner.java @@ -363,6 +363,7 @@ Expr prune(Expr node) { .setComprehensionExpr( Comprehension.newBuilder() .setIterVar(compre.getIterVar()) + .setIterVar2(compre.getIterVar2()) .setIterRange(newRange) .setAccuVar(compre.getAccuVar()) .setAccuInit(compre.getAccuInit()) diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java b/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java index 6eecaa5b..04b6e856 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/AttributeFactory.java @@ -216,7 +216,10 @@ public AttributeFactory.Attribute conditionalAttribute( @Override public Attribute maybeAttribute(long id, String name) { List attrs = new ArrayList<>(); - attrs.add(absoluteAttribute(id, container.resolveCandidateNames(name))); + attrs.add( + name.startsWith(".") + ? absoluteAttribute(id, name) + : absoluteAttribute(id, container.resolveCandidateNames(name))); return new MaybeAttribute(id, attrs, adapter, provider, this); } @@ -357,24 +360,16 @@ public Object resolve(org.projectnessie.cel.interpreter.Activation vars) { */ @Override public Object tryResolve(org.projectnessie.cel.interpreter.Activation vars) { + Object local = tryResolveCurrentVar(vars); + if (local != null) { + return local; + } for (String nm : namespaceNames) { // If the variable is found, process it. Otherwise, wait until the checks to // determine whether the type is unknown before returning. ResolvedValue obj = vars.resolveName(nm); if (obj.present()) { - Object op = obj.value(); - for (int i = 0; i < qualifiers.size(); i++) { - Qualifier qual = qualifiers.get(i); - Object op2 = qualify(vars, op, qual, i == qualifiers.size() - 1); - if (op2 instanceof Err) { - return op2; - } - if (op2 == null) { - break; - } - op = op2; - } - return op; + return resolveQualifiers(vars, obj.value()); } // Attempt to resolve the qualified type name if the name is not a variable identifier. Val typ = provider.findIdent(nm); @@ -388,6 +383,36 @@ public Object tryResolve(org.projectnessie.cel.interpreter.Activation vars) { throw noSuchAttributeException(this); } + private Object tryResolveCurrentVar(org.projectnessie.cel.interpreter.Activation vars) { + if (vars instanceof org.projectnessie.cel.interpreter.Activation.VarActivation + && (namespaceNames.length > 1 || !qualifiers.isEmpty())) { + org.projectnessie.cel.interpreter.Activation.VarActivation var = + (org.projectnessie.cel.interpreter.Activation.VarActivation) vars; + String localName = namespaceNames[namespaceNames.length - 1]; + if (localName.equals(var.name)) { + return resolveQualifiers(vars, var.val); + } + } + return null; + } + + private Object resolveQualifiers( + org.projectnessie.cel.interpreter.Activation vars, Object obj) { + Object op = obj; + for (int i = 0; i < qualifiers.size(); i++) { + Qualifier qual = qualifiers.get(i); + Object op2 = qualify(vars, op, qual, i == qualifiers.size() - 1); + if (op2 instanceof Err) { + return op2; + } + if (op2 == null) { + break; + } + op = op2; + } + return op; + } + private Object qualify( org.projectnessie.cel.interpreter.Activation vars, Object obj, @@ -624,6 +649,14 @@ public Object qualify(org.projectnessie.cel.interpreter.Activation vars, Object */ @Override public Object resolve(org.projectnessie.cel.interpreter.Activation vars) { + for (NamespacedAttribute attr : attrs) { + if (attr instanceof AbsoluteAttribute) { + Object result = ((AbsoluteAttribute) attr).tryResolveCurrentVar(vars); + if (result != null) { + return result; + } + } + } for (NamespacedAttribute attr : attrs) { try { return attr.tryResolve(vars); diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java index 1c8ee0c6..ed0c3f3d 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/Interpretable.java @@ -22,6 +22,7 @@ import static org.projectnessie.cel.common.types.Err.noSuchAttributeException; import static org.projectnessie.cel.common.types.Err.noSuchOverload; import static org.projectnessie.cel.common.types.Err.valOrErr; +import static org.projectnessie.cel.common.types.IntT.intOf; import static org.projectnessie.cel.common.types.UnknownT.isUnknown; import static org.projectnessie.cel.common.types.UnknownT.unknownOf; import static org.projectnessie.cel.common.types.Util.isUnknownOrError; @@ -47,11 +48,12 @@ import org.projectnessie.cel.common.types.StringT; import org.projectnessie.cel.common.types.ref.FieldType; import org.projectnessie.cel.common.types.ref.TypeAdapter; -import org.projectnessie.cel.common.types.ref.TypeEnum; import org.projectnessie.cel.common.types.ref.TypeProvider; import org.projectnessie.cel.common.types.ref.Val; import org.projectnessie.cel.common.types.traits.Container; import org.projectnessie.cel.common.types.traits.FieldTester; +import org.projectnessie.cel.common.types.traits.Lister; +import org.projectnessie.cel.common.types.traits.Mapper; import org.projectnessie.cel.common.types.traits.Negater; import org.projectnessie.cel.common.types.traits.Receiver; import org.projectnessie.cel.common.types.traits.Sizer; @@ -934,7 +936,7 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { if (isUnknownOrError(keyVal)) { return keyVal; } - if (keyVal.type().typeEnum() == TypeEnum.Null) { + if (!MapT.isSupportedLiteralKeyType(keyVal)) { return newErr("unsupported key type"); } Val valVal = vals[i].eval(ctx); @@ -1040,6 +1042,7 @@ final class EvalFold extends AbstractEval implements Coster { // TODO combine with EvalExhaustiveFold final String accuVar; final String iterVar; + final String iterVar2; final Interpretable iterRange; final Interpretable accu; final Interpretable cond; @@ -1051,6 +1054,7 @@ final class EvalFold extends AbstractEval implements Coster { String accuVar, Interpretable accu, String iterVar, + String iterVar2, Interpretable iterRange, Interpretable cond, Interpretable step, @@ -1058,6 +1062,7 @@ final class EvalFold extends AbstractEval implements Coster { super(id); this.accuVar = accuVar; this.iterVar = iterVar; + this.iterVar2 = iterVar2; this.iterRange = iterRange; this.accu = accu; this.cond = cond; @@ -1081,19 +1086,43 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = accuCtx; iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { // Modify the iter var in the fold activation. - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; // Evaluate the condition, terminate the loop if false. - Val c = cond.eval(iterCtx); + Val c = cond.eval(loopCtx); if (c == False) { break; } // Evalute the evaluation step into accu var. - accuCtx.val = step.eval(iterCtx); + accuCtx.val = step.eval(loopCtx); } // Compute the result. return result.eval(accuCtx); @@ -1142,6 +1171,9 @@ public String toString() { + ", iterVar='" + iterVar + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + ", iterRange=" + iterRange + ", accu=" @@ -1158,6 +1190,7 @@ public String toString() { final class EvalListFold extends AbstractEval implements Coster { final String iterVar; + final String iterVar2; final Interpretable iterRange; final Interpretable filter; final Interpretable transform; @@ -1166,12 +1199,14 @@ final class EvalListFold extends AbstractEval implements Coster { EvalListFold( long id, String iterVar, + String iterVar2, Interpretable iterRange, Interpretable filter, Interpretable transform, TypeAdapter adapter) { super(id); this.iterVar = iterVar; + this.iterVar2 = iterVar2; this.iterRange = iterRange; this.filter = filter; this.transform = transform; @@ -1189,13 +1224,37 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = ctx; iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } List values = new ArrayList<>(listCapacity(foldRange)); IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; if (filter != null) { - Val include = filter.eval(iterCtx); + Val include = filter.eval(loopCtx); if (include == False) { continue; } @@ -1204,7 +1263,7 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { } } - Val value = transform.eval(iterCtx); + Val value = transform.eval(loopCtx); if (isUnknownOrError(value)) { return value; } @@ -1251,6 +1310,149 @@ public String toString() { + ", iterVar='" + iterVar + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + + ", iterRange=" + + iterRange + + ", filter=" + + filter + + ", transform=" + + transform + + '}'; + } + } + + final class EvalMapFold extends AbstractEval implements Coster { + final String iterVar; + final String iterVar2; + final Interpretable iterRange; + final Interpretable filter; + final Interpretable transform; + private final TypeAdapter adapter; + + EvalMapFold( + long id, + String iterVar, + String iterVar2, + Interpretable iterRange, + Interpretable filter, + Interpretable transform, + TypeAdapter adapter) { + super(id); + this.iterVar = iterVar; + this.iterVar2 = iterVar2; + this.iterRange = iterRange; + this.filter = filter; + this.transform = transform; + this.adapter = adapter; + } + + @Override + public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { + Val foldRange = iterRange.eval(ctx); + if (!foldRange.type().hasTrait(Trait.IterableType)) { + return valOrErr( + foldRange, "got '%s', expected iterable type", foldRange.getClass().getName()); + } + + VarActivation iterCtx = new VarActivation(); + iterCtx.parent = ctx; + iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } + Map values = new HashMap<>(mapCapacity(foldRange)); + IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; + while (it.hasNext() == True) { + Val next = it.next(); + Val key; + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + key = intOf(index); + iterCtx.val = key; + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + key = next; + iterCtx.val = key; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + key = next; + iterCtx.val = next; + } + index++; + + if (filter != null) { + Val include = filter.eval(loopCtx); + if (include == False) { + continue; + } + if (include != True) { + return noSuchOverload(null, Operator.Conditional.id, include); + } + } + + Val value = transform.eval(loopCtx); + if (isUnknownOrError(value)) { + return value; + } + values.put(key, value); + } + return MapT.newWrappedMap(adapter, values); + } + + private int mapCapacity(Val foldRange) { + if (foldRange.type().hasTrait(Trait.SizerType)) { + long size = ((Sizer) foldRange).size().intValue(); + if (size > 0 && size <= Integer.MAX_VALUE) { + long capacity = size * 4 / 3 + 1; + return capacity <= Integer.MAX_VALUE ? (int) capacity : Integer.MAX_VALUE; + } + } + return 0; + } + + @Override + public Cost cost() { + Cost range = estimateCost(iterRange); + Cost result = estimateCost(transform); + if (filter != null) { + result = result.add(estimateCost(filter)); + } + Val foldRange = iterRange.eval(emptyActivation()); + if (!foldRange.type().hasTrait(Trait.IterableType)) { + return Cost.Unknown; + } + long rangeCnt = 0L; + IteratorT it = ((IterableT) foldRange).iterator(); + while (it.hasNext() == True) { + it.next(); + rangeCnt++; + } + return range.add(result.multiply(rangeCnt)); + } + + @Override + public String toString() { + return "EvalMapFold{" + + "id=" + + id + + ", iterVar='" + + iterVar + + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + ", iterRange=" + iterRange + ", filter=" @@ -1854,6 +2056,7 @@ final class EvalExhaustiveFold extends AbstractEval implements Coster { // TODO combine with EvalFold private final String accuVar; private final String iterVar; + private final String iterVar2; private final Interpretable iterRange; private final Interpretable accu; private final Interpretable cond; @@ -1866,12 +2069,14 @@ final class EvalExhaustiveFold extends AbstractEval implements Coster { String accuVar, Interpretable iterRange, String iterVar, + String iterVar2, Interpretable cond, Interpretable step, Interpretable result) { super(id); this.accuVar = accuVar; this.iterVar = iterVar; + this.iterVar2 = iterVar2; this.iterRange = iterRange; this.accu = accu; this.cond = cond; @@ -1895,16 +2100,40 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = accuCtx; iterCtx.name = iterVar; + VarActivation iterCtx2 = null; + if (!iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = iterVar2; + } IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { // Modify the iter var in the fold activation. - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; // Evaluate the condition, but don't terminate the loop as this is exhaustive eval! - cond.eval(iterCtx); + cond.eval(loopCtx); // Evalute the evaluation step into accu var. - accuCtx.val = step.eval(iterCtx); + accuCtx.val = step.eval(loopCtx); } // Compute the result. return result.eval(accuCtx); @@ -1950,6 +2179,9 @@ public String toString() { + ", iterVar='" + iterVar + '\'' + + ", iterVar2='" + + iterVar2 + + '\'' + ", iterRange=" + iterRange + ", accu=" @@ -1984,14 +2216,38 @@ public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { VarActivation iterCtx = new VarActivation(); iterCtx.parent = ctx; iterCtx.name = fold.iterVar; + VarActivation iterCtx2 = null; + if (!fold.iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = fold.iterVar2; + } List values = new ArrayList<>(fold.listCapacity(foldRange)); Val result = null; IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; while (it.hasNext() == True) { - iterCtx.val = it.next(); + Val next = it.next(); + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + iterCtx.val = intOf(index); + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + iterCtx.val = next; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + iterCtx.val = next; + } + index++; - Val include = fold.filter != null ? fold.filter.eval(iterCtx) : True; - Val value = fold.transform.eval(iterCtx); + Val include = fold.filter != null ? fold.filter.eval(loopCtx) : True; + Val value = fold.transform.eval(loopCtx); if (include == False) { continue; } @@ -2023,6 +2279,91 @@ public String toString() { } } + /** EvalExhaustiveMapFold evaluates every filter and transform without short-circuiting. */ + final class EvalExhaustiveMapFold extends AbstractEval implements Coster { + private final EvalMapFold fold; + + EvalExhaustiveMapFold(EvalMapFold fold) { + super(fold.id); + this.fold = fold; + } + + @Override + public Val eval(org.projectnessie.cel.interpreter.Activation ctx) { + Val foldRange = fold.iterRange.eval(ctx); + if (!foldRange.type().hasTrait(Trait.IterableType)) { + return valOrErr( + foldRange, "got '%s', expected iterable type", foldRange.getClass().getName()); + } + + VarActivation iterCtx = new VarActivation(); + iterCtx.parent = ctx; + iterCtx.name = fold.iterVar; + VarActivation iterCtx2 = null; + if (!fold.iterVar2.isEmpty()) { + iterCtx2 = new VarActivation(); + iterCtx2.parent = iterCtx; + iterCtx2.name = fold.iterVar2; + } + Map values = new HashMap<>(fold.mapCapacity(foldRange)); + Val result = null; + IteratorT it = ((IterableT) foldRange).iterator(); + long index = 0L; + while (it.hasNext() == True) { + Val next = it.next(); + Val key; + Activation loopCtx = iterCtx; + if (iterCtx2 != null) { + if (foldRange instanceof Lister) { + key = intOf(index); + iterCtx.val = key; + iterCtx2.val = next; + } else if (foldRange instanceof Mapper) { + key = next; + iterCtx.val = key; + iterCtx2.val = ((Mapper) foldRange).get(next); + } else { + return valOrErr( + foldRange, "got '%s', expected list or map type", foldRange.getClass().getName()); + } + loopCtx = iterCtx2; + } else { + key = next; + iterCtx.val = next; + } + index++; + + Val include = fold.filter != null ? fold.filter.eval(loopCtx) : True; + Val value = fold.transform.eval(loopCtx); + if (include == False) { + continue; + } + if (include != True) { + result = noSuchOverload(null, Operator.Conditional.id, include); + continue; + } + if (result == null) { + if (isUnknownOrError(value)) { + result = value; + } else { + values.put(key, value); + } + } + } + return result != null ? result : MapT.newWrappedMap(fold.adapter, values); + } + + @Override + public Cost cost() { + return fold.cost(); + } + + @Override + public String toString() { + return "EvalExhaustiveMapFold{" + fold + '}'; + } + } + /** evalAttr evaluates an Attribute value. */ final class EvalAttr extends AbstractEval implements InterpretableAttribute, Coster, Qualifier, Attribute { diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java index eb4f9978..a7665ef1 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretableDecorator.java @@ -36,11 +36,13 @@ import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveConditional; import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveFold; import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveListFold; +import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveMapFold; import org.projectnessie.cel.interpreter.Interpretable.EvalExhaustiveOr; import org.projectnessie.cel.interpreter.Interpretable.EvalFold; import org.projectnessie.cel.interpreter.Interpretable.EvalList; import org.projectnessie.cel.interpreter.Interpretable.EvalListFold; import org.projectnessie.cel.interpreter.Interpretable.EvalMap; +import org.projectnessie.cel.interpreter.Interpretable.EvalMapFold; import org.projectnessie.cel.interpreter.Interpretable.EvalOr; import org.projectnessie.cel.interpreter.Interpretable.EvalSetMembership; import org.projectnessie.cel.interpreter.Interpretable.EvalWatch; @@ -105,6 +107,7 @@ static InterpretableDecorator decDisableShortcircuits() { expr.accuVar, expr.iterRange, expr.iterVar, + expr.iterVar2, expr.cond, expr.step, expr.result); @@ -112,6 +115,9 @@ static InterpretableDecorator decDisableShortcircuits() { if (i instanceof EvalListFold) { return new EvalExhaustiveListFold((EvalListFold) i); } + if (i instanceof EvalMapFold) { + return new EvalExhaustiveMapFold((EvalMapFold) i); + } if (i instanceof InterpretableAttribute) { InterpretableAttribute expr = (InterpretableAttribute) i; if (expr.attr() instanceof ConditionalAttribute) { diff --git a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java index 7d9c7416..f550e22a 100644 --- a/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java +++ b/core/src/main/java/org/projectnessie/cel/interpreter/InterpretablePlanner.java @@ -59,6 +59,7 @@ import org.projectnessie.cel.interpreter.Interpretable.EvalList; import org.projectnessie.cel.interpreter.Interpretable.EvalListFold; import org.projectnessie.cel.interpreter.Interpretable.EvalMap; +import org.projectnessie.cel.interpreter.Interpretable.EvalMapFold; import org.projectnessie.cel.interpreter.Interpretable.EvalNe; import org.projectnessie.cel.interpreter.Interpretable.EvalObj; import org.projectnessie.cel.interpreter.Interpretable.EvalOr; @@ -223,6 +224,8 @@ Interpretable planIdent(Expr expr) { } Interpretable planCheckedIdent(long id, Reference identRef) { + String identName = identRef.getName(); + String providerName = identName.startsWith(".") ? identName.substring(1) : identName; // Plan a constant reference if this is the case for this simple identifier. if (identRef.getValue() != Reference.getDefaultInstance().getValue()) { return plan(Expr.newBuilder().setId(id).setConstExpr(identRef.getValue()).build()); @@ -232,10 +235,10 @@ Interpretable planCheckedIdent(long id, Reference identRef) { // registered with the provider. Type cType = typeMap.get(id); if (cType != null && cType.getType() != Type.getDefaultInstance()) { - Val cVal = provider.findIdent(identRef.getName()); + Val cVal = provider.findIdent(providerName); if (cVal == null) { throw new IllegalStateException( - String.format("reference to undefined type: %s", identRef.getName())); + String.format("reference to undefined type: %s", providerName)); } return newConstValue(id, cVal); } @@ -243,9 +246,9 @@ Interpretable planCheckedIdent(long id, Reference identRef) { // Otherwise, evaluate the checked top-level variable directly for ordinary plans. Decorated // programs keep the attribute shape because custom decorators may inspect attributes. if (decorators.length == 0) { - return new EvalIdent(id, identRef.getName(), adapter); + return new EvalIdent(id, identName, adapter); } - return new EvalAttr(adapter, attrFactory.absoluteAttribute(id, identRef.getName())); + return new EvalAttr(adapter, attrFactory.absoluteAttribute(id, identName)); } /** @@ -619,6 +622,33 @@ Interpretable planCreateObj(Expr expr) { /** planComprehension generates an Interpretable fold operation. */ Interpretable planComprehension(Expr expr) { Comprehension fold = expr.getComprehensionExpr(); + MacroMapFold macroMapFold = macroMapFold(fold); + if (macroMapFold != null) { + Interpretable iterRange = plan(fold.getIterRange()); + if (iterRange == null) { + return null; + } + Interpretable filter = null; + if (macroMapFold.filter != null) { + filter = plan(macroMapFold.filter); + if (filter == null) { + return null; + } + } + Interpretable transform = plan(macroMapFold.transform); + if (transform == null) { + return null; + } + return new EvalMapFold( + expr.getId(), + fold.getIterVar(), + fold.getIterVar2(), + iterRange, + filter, + transform, + adapter); + } + MacroListFold macroListFold = macroListFold(fold); if (macroListFold != null) { Interpretable iterRange = plan(fold.getIterRange()); @@ -637,7 +667,13 @@ Interpretable planComprehension(Expr expr) { return null; } return new EvalListFold( - expr.getId(), fold.getIterVar(), iterRange, filter, transform, adapter); + expr.getId(), + fold.getIterVar(), + fold.getIterVar2(), + iterRange, + filter, + transform, + adapter); } Interpretable accu = plan(fold.getAccuInit()); @@ -661,7 +697,42 @@ Interpretable planComprehension(Expr expr) { return null; } return new EvalFold( - expr.getId(), fold.getAccuVar(), accu, fold.getIterVar(), iterRange, cond, step, result); + expr.getId(), + fold.getAccuVar(), + accu, + fold.getIterVar(), + fold.getIterVar2(), + iterRange, + cond, + step, + result); + } + + private static MacroMapFold macroMapFold(Comprehension fold) { + if (!isEmptyMap(fold.getAccuInit()) + || !isBoolConst(fold.getLoopCondition(), true) + || !isIdent(fold.getResult(), fold.getAccuVar())) { + return null; + } + + Expr step = fold.getLoopStep(); + Expr filter = null; + if (isCall(step, Operator.Conditional.id, 3)) { + Call conditional = step.getCallExpr(); + if (!isIdent(conditional.getArgs(2), fold.getAccuVar())) { + return null; + } + filter = conditional.getArgs(0); + step = conditional.getArgs(1); + } + + Expr transform = mapEntryValue(fold.getIterVar(), step); + if (transform == null + || referencesIdent(transform, fold.getAccuVar()) + || (filter != null && referencesIdent(filter, fold.getAccuVar()))) { + return null; + } + return new MacroMapFold(filter, transform); } private static MacroListFold macroListFold(Comprehension fold) { @@ -724,6 +795,25 @@ private static boolean isEmptyList(Expr expr) { && expr.getListExpr().getElementsCount() == 0; } + private static boolean isEmptyMap(Expr expr) { + return expr.getExprKindCase() == Expr.ExprKindCase.STRUCT_EXPR + && expr.getStructExpr().getMessageName().isEmpty() + && expr.getStructExpr().getEntriesCount() == 0; + } + + private static Expr mapEntryValue(String keyVar, Expr step) { + if (step.getExprKindCase() != Expr.ExprKindCase.STRUCT_EXPR + || !step.getStructExpr().getMessageName().isEmpty() + || step.getStructExpr().getEntriesCount() != 1) { + return null; + } + Entry entry = step.getStructExpr().getEntries(0); + if (!isIdent(entry.getMapKey(), keyVar)) { + return null; + } + return entry.getValue(); + } + private static boolean isBoolConst(Expr expr, boolean value) { return expr.getExprKindCase() == Expr.ExprKindCase.CONST_EXPR && expr.getConstExpr().getConstantKindCase() == Constant.ConstantKindCase.BOOL_VALUE @@ -766,9 +856,11 @@ private static boolean referencesIdent(Expr expr, String name) { return referencesIdent(comprehension.getIterRange(), name) || referencesIdent(comprehension.getAccuInit(), name) || (!comprehension.getIterVar().equals(name) + && !comprehension.getIterVar2().equals(name) && !comprehension.getAccuVar().equals(name) && referencesIdent(comprehension.getLoopCondition(), name)) || (!comprehension.getIterVar().equals(name) + && !comprehension.getIterVar2().equals(name) && !comprehension.getAccuVar().equals(name) && referencesIdent(comprehension.getLoopStep(), name)) || (!comprehension.getAccuVar().equals(name) @@ -788,6 +880,16 @@ private MacroListFold(Expr filter, Expr transform) { } } + private static final class MacroMapFold { + final Expr filter; + final Expr transform; + + private MacroMapFold(Expr filter, Expr transform) { + this.filter = filter; + this.transform = transform; + } + } + /** planConst generates a constant valued Interpretable. */ static Interpretable planConst(Expr expr) { Val val = constValue(expr.getConstExpr()); diff --git a/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java b/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java index 286e3e32..5a24fd85 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java +++ b/core/src/main/java/org/projectnessie/cel/parser/ExprHelper.java @@ -40,6 +40,9 @@ public interface ExprHelper { /** LiteralInt creates an Expr value for an int literal. */ Expr literalInt(long value); + /** LiteralNull creates an Expr value for a null literal. */ + Expr literalNull(); + /** LiteralString creates am Expr value for a string literal. */ Expr literalString(String value); @@ -100,6 +103,17 @@ Expr fold( Expr step, Expr result); + /** Fold creates a fold comprehension instruction with two iteration variables. */ + Expr fold( + String iterVar, + String iterVar2, + Expr iterRange, + String accuVar, + Expr accuInit, + Expr condition, + Expr step, + Expr result); + /** Ident creates an identifier Expr value. */ Expr ident(String name); diff --git a/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java b/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java index 457a576f..0c39b695 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java +++ b/core/src/main/java/org/projectnessie/cel/parser/ExprHelperImpl.java @@ -60,6 +60,12 @@ public Expr literalInt(long value) { return parserHelper.newLiteralInt(nextMacroID(), value); } + // LiteralNull implements the ExprHelper interface method. + @Override + public Expr literalNull() { + return parserHelper.newLiteralNull(nextMacroID()); + } + // LiteralString implements the ExprHelper interface method. @Override public Expr literalString(String value) { @@ -121,6 +127,20 @@ public Expr fold( nextMacroID(), iterVar, iterRange, accuVar, accuInit, condition, step, result); } + @Override + public Expr fold( + String iterVar, + String iterVar2, + Expr iterRange, + String accuVar, + Expr accuInit, + Expr condition, + Expr step, + Expr result) { + return parserHelper.newComprehension( + nextMacroID(), iterVar, iterVar2, iterRange, accuVar, accuInit, condition, step, result); + } + // Ident implements the ExprHelper interface method. @Override public Expr ident(String name) { diff --git a/core/src/main/java/org/projectnessie/cel/parser/Helper.java b/core/src/main/java/org/projectnessie/cel/parser/Helper.java index edea6552..5e9f1a61 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Helper.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Helper.java @@ -27,6 +27,7 @@ import com.google.api.expr.v1alpha1.Expr.Select; import com.google.api.expr.v1alpha1.SourceInfo; import com.google.protobuf.ByteString; +import com.google.protobuf.NullValue; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -77,6 +78,10 @@ Expr newLiteralInt(Object ctx, long value) { return newLiteral(ctx, Constant.newBuilder().setInt64Value(value)); } + Expr newLiteralNull(Object ctx) { + return newLiteral(ctx, Constant.newBuilder().setNullValue(NullValue.NULL_VALUE)); + } + Expr newLiteralUint(Object ctx, long value) { return newLiteral(ctx, Constant.newBuilder().setUint64Value(value)); } @@ -152,12 +157,27 @@ Expr newComprehension( Expr condition, Expr step, Expr result) { + return newComprehension( + ctx, iterVar, "", iterRange, accuVar, accuInit, condition, step, result); + } + + Expr newComprehension( + Object ctx, + String iterVar, + String iterVar2, + Expr iterRange, + String accuVar, + Expr accuInit, + Expr condition, + Expr step, + Expr result) { return newExprBuilder(ctx) .setComprehensionExpr( Comprehension.newBuilder() .setAccuVar(accuVar) .setAccuInit(accuInit) .setIterVar(iterVar) + .setIterVar2(iterVar2) .setIterRange(iterRange) .setLoopCondition(condition) .setLoopStep(step) diff --git a/core/src/main/java/org/projectnessie/cel/parser/Macro.java b/core/src/main/java/org/projectnessie/cel/parser/Macro.java index ba507490..374289c1 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Macro.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Macro.java @@ -19,6 +19,8 @@ import static java.util.Collections.emptyList; import com.google.api.expr.v1alpha1.Expr; +import com.google.api.expr.v1alpha1.Expr.CreateList; +import com.google.api.expr.v1alpha1.Expr.CreateStruct.Entry; import com.google.api.expr.v1alpha1.Expr.ExprKindCase; import com.google.api.expr.v1alpha1.Expr.Select; import java.util.List; @@ -26,6 +28,7 @@ import org.projectnessie.cel.common.ErrorWithLocation; import org.projectnessie.cel.common.Location; import org.projectnessie.cel.common.operators.Operator; +import org.projectnessie.cel.common.types.Overloads; public final class Macro { /** AccumulatorName is the traditional variable name assigned to the fold accumulator variable. */ @@ -43,16 +46,20 @@ public final class Macro { * predicate holds. */ newReceiverMacro(Operator.All.id, 2, Macro::makeAll), + newReceiverMacro(Operator.All.id, 3, Macro::makeAll2), /* The macro "range.exists(var, predicate)", which is true if for at least one element in * range the predicate holds. */ newReceiverMacro(Operator.Exists.id, 2, Macro::makeExists), + newReceiverMacro(Operator.Exists.id, 3, Macro::makeExists2), /* The macro "range.exists_one(var, predicate)", which is true if for exactly one element * in range the predicate holds. */ newReceiverMacro(Operator.ExistsOne.id, 2, Macro::makeExistsOne), + newReceiverMacro(Operator.ExistsOne.id, 3, Macro::makeExistsOne2), + newReceiverMacro("existsOne", 3, Macro::makeExistsOne2), /* The macro "range.map(var, function)", applies the function to the vars in the range. */ newReceiverMacro(Operator.Map.id, 2, Macro::makeMap), @@ -65,7 +72,26 @@ public final class Macro { /* The macro "range.filter(var, predicate)", filters out the variables for which the * predicate is false. */ - newReceiverMacro(Operator.Filter.id, 2, Macro::makeFilter)); + newReceiverMacro(Operator.Filter.id, 2, Macro::makeFilter), + + /* The macro "range.transformList(index, value, function)" transforms entries to a list. */ + newReceiverMacro("transformList", 3, Macro::makeTransformList), + newReceiverMacro("transformList", 4, Macro::makeTransformList), + + /* The macro "range.transformMap(key, value, function)" transforms map values. */ + newReceiverMacro("transformMap", 3, Macro::makeTransformMap), + newReceiverMacro("transformMap", 4, Macro::makeTransformMap), + + /* The macro "cel.bind(var, value, result)" binds a local variable for use in result. */ + newReceiverMacro("bind", 3, Macro::makeBind)); + + /** TestOnlyBlockMacros includes the test-only macros used by CEL-Spec block conformance tests. */ + public static final List TestOnlyBlockMacros = + asList( + newReceiverMacro("block", 2, Macro::makeBlock), + newReceiverMacro("index", 1, Macro::makeIndex), + newReceiverMacro("iterVar", 2, Macro::makeIterVar), + newReceiverMacro("accuVar", 2, Macro::makeAccuVar)); /** NoMacros list. */ public static List MoMacros = emptyList(); @@ -138,14 +164,26 @@ static Expr makeAll(ExprHelper eh, Expr target, List args) { return makeQuantifier(QuantifierKind.quantifierAll, eh, target, args); } + static Expr makeAll2(ExprHelper eh, Expr target, List args) { + return makeQuantifier2(QuantifierKind.quantifierAll, eh, target, args); + } + static Expr makeExists(ExprHelper eh, Expr target, List args) { return makeQuantifier(QuantifierKind.quantifierExists, eh, target, args); } + static Expr makeExists2(ExprHelper eh, Expr target, List args) { + return makeQuantifier2(QuantifierKind.quantifierExists, eh, target, args); + } + static Expr makeExistsOne(ExprHelper eh, Expr target, List args) { return makeQuantifier(QuantifierKind.quantifierExistsOne, eh, target, args); } + static Expr makeExistsOne2(ExprHelper eh, Expr target, List args) { + return makeQuantifier2(QuantifierKind.quantifierExistsOne, eh, target, args); + } + static Expr makeQuantifier(QuantifierKind kind, ExprHelper eh, Expr target, List args) { String v = extractIdent(args.get(0)); if (v == null) { @@ -194,6 +232,59 @@ static Expr makeQuantifier(QuantifierKind kind, ExprHelper eh, Expr target, List return eh.fold(v, target, AccumulatorName, init, condition, step, result); } + static Expr makeQuantifier2(QuantifierKind kind, ExprHelper eh, Expr target, List args) { + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + String v2 = extractIdent(args.get(1)); + if (v2 == null) { + Location location = eh.offsetLocation(args.get(1).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Supplier accuIdent = () -> eh.ident(AccumulatorName); + + Expr init; + Expr condition; + Expr step; + Expr result; + switch (kind) { + case quantifierAll: + init = eh.literalBool(true); + condition = eh.globalCall(Operator.NotStrictlyFalse.id, accuIdent.get()); + step = eh.globalCall(Operator.LogicalAnd.id, accuIdent.get(), args.get(2)); + result = accuIdent.get(); + break; + case quantifierExists: + init = eh.literalBool(false); + condition = + eh.globalCall( + Operator.NotStrictlyFalse.id, + eh.globalCall(Operator.LogicalNot.id, accuIdent.get())); + step = eh.globalCall(Operator.LogicalOr.id, accuIdent.get(), args.get(2)); + result = accuIdent.get(); + break; + case quantifierExistsOne: + Expr zeroExpr = eh.literalInt(0); + Expr oneExpr = eh.literalInt(1); + init = zeroExpr; + condition = eh.literalBool(true); + step = + eh.globalCall( + Operator.Conditional.id, + args.get(2), + eh.globalCall(Operator.Add.id, accuIdent.get(), oneExpr), + accuIdent.get()); + result = eh.globalCall(Operator.Equals.id, accuIdent.get(), oneExpr); + break; + default: + throw new ErrorWithLocation(null, String.format("unrecognized quantifier '%s'", kind)); + } + return eh.fold(v, v2, target, AccumulatorName, init, condition, step, result); + } + static Expr makeMap(ExprHelper eh, Expr target, List args) { String v = extractIdent(args.get(0)); if (v == null) { @@ -237,6 +328,185 @@ static Expr makeFilter(ExprHelper eh, Expr target, List args) { return eh.fold(v, target, AccumulatorName, init, condition, step, accuExpr); } + static Expr makeTransformList(ExprHelper eh, Expr target, List args) { + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + String v2 = extractIdent(args.get(1)); + if (v2 == null) { + Location location = eh.offsetLocation(args.get(1).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Expr fn; + Expr filter; + + if (args.size() == 4) { + filter = args.get(2); + fn = args.get(3); + } else { + filter = null; + fn = args.get(2); + } + + Expr accuExpr = eh.ident(AccumulatorName); + Expr init = eh.newList(); + Expr condition = eh.literalBool(true); + Expr step = eh.globalCall(Operator.Add.id, accuExpr, eh.newList(fn)); + + if (filter != null) { + step = eh.globalCall(Operator.Conditional.id, filter, step, accuExpr); + } + return eh.fold(v, v2, target, AccumulatorName, init, condition, step, accuExpr); + } + + static Expr makeTransformMap(ExprHelper eh, Expr target, List args) { + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + String v2 = extractIdent(args.get(1)); + if (v2 == null) { + Location location = eh.offsetLocation(args.get(1).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Expr fn; + Expr filter; + + if (args.size() == 4) { + filter = args.get(2); + fn = args.get(3); + } else { + filter = null; + fn = args.get(2); + } + + Expr accuExpr = eh.ident(AccumulatorName); + Expr init = eh.newMap(emptyList()); + Expr condition = eh.literalBool(true); + Entry transformedEntry = eh.newMapEntry(eh.ident(v), fn); + Expr step = eh.newMap(asList(transformedEntry)); + + if (filter != null) { + step = eh.globalCall(Operator.Conditional.id, filter, step, accuExpr); + } + return eh.fold(v, v2, target, AccumulatorName, init, condition, step, accuExpr); + } + + static Expr makeBind(ExprHelper eh, Expr target, List args) { + if (target == null + || target.getExprKindCase() != ExprKindCase.IDENT_EXPR + || !"cel".equals(target.getIdentExpr().getName())) { + Location location = target != null ? eh.offsetLocation(target.getId()) : null; + throw new ErrorWithLocation( + location, "cel.bind() must be called with receiver identifier cel"); + } + + String v = extractIdent(args.get(0)); + if (v == null) { + Location location = eh.offsetLocation(args.get(0).getId()); + throw new ErrorWithLocation(location, "argument must be a simple name"); + } + + Expr accuExpr = eh.ident(AccumulatorName); + Expr dynNull = eh.globalCall(Overloads.TypeConvertDyn, eh.literalNull()); + return eh.fold( + v, + eh.newList(args.get(1)), + AccumulatorName, + dynNull, + eh.literalBool(true), + args.get(2), + accuExpr); + } + + static Expr makeBlock(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.block()"); + + Expr bindings = args.get(0); + if (bindings.getExprKindCase() != ExprKindCase.LIST_EXPR) { + Location location = eh.offsetLocation(bindings.getId()); + throw new ErrorWithLocation(location, "cel.block() first argument must be a list literal"); + } + + CreateList list = bindings.getListExpr(); + Expr result = args.get(1); + for (int i = list.getElementsCount() - 1; i >= 0; i--) { + result = makeLocalBinding(eh, indexName(i), list.getElements(i), result); + } + return result; + } + + static Expr makeIndex(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.index()"); + return eh.ident(indexName(extractIntegerArgument(eh, args.get(0), "cel.index()"))); + } + + static Expr makeIterVar(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.iterVar()"); + return eh.ident( + String.format( + "@it:%d:%d", + extractIntegerArgument(eh, args.get(0), "cel.iterVar()"), + extractIntegerArgument(eh, args.get(1), "cel.iterVar()"))); + } + + static Expr makeAccuVar(ExprHelper eh, Expr target, List args) { + validateCelReceiver(eh, target, "cel.accuVar()"); + return eh.ident( + String.format( + "@ac:%d:%d", + extractIntegerArgument(eh, args.get(0), "cel.accuVar()"), + extractIntegerArgument(eh, args.get(1), "cel.accuVar()"))); + } + + private static Expr makeLocalBinding(ExprHelper eh, String variable, Expr value, Expr result) { + Expr accuExpr = eh.ident(AccumulatorName); + Expr dynNull = eh.globalCall(Overloads.TypeConvertDyn, eh.literalNull()); + return eh.fold( + variable, + eh.newList(value), + AccumulatorName, + dynNull, + eh.literalBool(true), + result, + accuExpr); + } + + private static void validateCelReceiver(ExprHelper eh, Expr target, String macroName) { + if (target != null + && target.getExprKindCase() == ExprKindCase.IDENT_EXPR + && "cel".equals(target.getIdentExpr().getName())) { + return; + } + + Location location = target != null ? eh.offsetLocation(target.getId()) : null; + throw new ErrorWithLocation( + location, macroName + " must be called with receiver identifier cel"); + } + + private static int extractIntegerArgument(ExprHelper eh, Expr expr, String macroName) { + if (expr.getExprKindCase() != ExprKindCase.CONST_EXPR || !expr.getConstExpr().hasInt64Value()) { + Location location = eh.offsetLocation(expr.getId()); + throw new ErrorWithLocation(location, macroName + " argument must be an integer literal"); + } + + long value = expr.getConstExpr().getInt64Value(); + if (value < 0 || value > Integer.MAX_VALUE) { + Location location = eh.offsetLocation(expr.getId()); + throw new ErrorWithLocation(location, macroName + " argument out of range"); + } + return (int) value; + } + + private static String indexName(int index) { + return "@index" + index; + } + static String extractIdent(Expr e) { if (e.getExprKindCase() == ExprKindCase.IDENT_EXPR) { return e.getIdentExpr().getName(); diff --git a/core/src/main/java/org/projectnessie/cel/parser/Parser.java b/core/src/main/java/org/projectnessie/cel/parser/Parser.java index 832cbecd..a59ea8a8 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Parser.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Parser.java @@ -52,6 +52,7 @@ import org.projectnessie.cel.parser.gen.CELParser.DoubleContext; import org.projectnessie.cel.parser.gen.CELParser.ExprContext; import org.projectnessie.cel.parser.gen.CELParser.ExprListContext; +import org.projectnessie.cel.parser.gen.CELParser.FieldContext; import org.projectnessie.cel.parser.gen.CELParser.FieldInitializerListContext; import org.projectnessie.cel.parser.gen.CELParser.IdentOrGlobalCallContext; import org.projectnessie.cel.parser.gen.CELParser.IndexContext; @@ -694,14 +695,14 @@ List visitIFieldInitializerList(FieldInitializerListContext ctx) { List cols = ctx.cols; List vals = ctx.values; for (int i = 0; i < ctx.fields.size(); i++) { - Token f = ctx.fields.get(i); + FieldContext f = ctx.fields.get(i); if (i >= cols.size() || i >= vals.size()) { // This is the result of a syntax error detected elsewhere. return Collections.emptyList(); } long initID = helper.id(cols.get(i)); Expr value = exprVisit(vals.get(i)); - Entry field = helper.newObjectField(initID, f.getText(), value); + Entry field = helper.newObjectField(initID, fieldName(f), value); result.add(field); } return result; @@ -739,7 +740,7 @@ private Expr visitSelectOrCall(SelectOrCallContext ctx) { if (ctx.id == null) { return helper.newExpr(ctx); } - String id = ctx.id.getText(); + String id = fieldName(ctx.id); if (ctx.open != null) { long opID = helper.id(ctx.open); return receiverCallOrMacro(opID, id, operand, visitList(ctx.args)); @@ -747,6 +748,14 @@ private Expr visitSelectOrCall(SelectOrCallContext ctx) { return helper.newSelect(ctx.op, operand, id); } + private String fieldName(FieldContext ctx) { + String text = ctx.getText(); + if (text.length() >= 2 && text.charAt(0) == '`' && text.charAt(text.length() - 1) == '`') { + return text.substring(1, text.length() - 1); + } + return text; + } + private List visitMapInitializerList(MapInitializerListContext ctx) { if (ctx == null || ctx.keys.isEmpty()) { // This is the result of a syntax error handled elswhere, return empty. diff --git a/core/src/main/java/org/projectnessie/cel/parser/Unescape.java b/core/src/main/java/org/projectnessie/cel/parser/Unescape.java index dc260f3f..a6a8ef35 100644 --- a/core/src/main/java/org/projectnessie/cel/parser/Unescape.java +++ b/core/src/main/java/org/projectnessie/cel/parser/Unescape.java @@ -90,7 +90,7 @@ public static ByteBuffer unescape(String value, boolean isBytes) { } // Otherwise the string contains escape characters. - ByteBuffer buf = ByteBuffer.allocate(value.length() * 3 / 2); + ByteBuffer buf = ByteBuffer.allocate(value.length() * 4); for (int i = 0; i < n; i++) { char c = value.charAt(i); if (c == '\\') { @@ -215,7 +215,11 @@ public static ByteBuffer unescape(String value, boolean isBytes) { } else { // not an escape sequence if (!isBytes) { - encodeCodePoint(buf, c, cb, enc); + int codePoint = value.codePointAt(i); + encodeCodePoint(buf, codePoint, cb, enc); + if (Character.charCount(codePoint) == 2) { + i++; + } } else { buf.put((byte) c); } diff --git a/core/src/test/java/org/projectnessie/cel/CELTest.java b/core/src/test/java/org/projectnessie/cel/CELTest.java index c2cd488d..389f0dec 100644 --- a/core/src/test/java/org/projectnessie/cel/CELTest.java +++ b/core/src/test/java/org/projectnessie/cel/CELTest.java @@ -126,6 +126,103 @@ void AstToProto() { assertThat(ast3.getExpr()).isEqualTo(astIss2.getAst().getExpr()); } + @Test + void comprehensionLocalVariablesShadowNamespacedIdentifiers() { + Env env = + newEnv( + container("com.example"), + declarations( + Decls.newVar("com.example.y", Decls.Int), + Decls.newVar("y", Decls.String), + Decls.newVar("com.example.y.z", Decls.Int))); + + AstIssuesTuple astIss = env.compile("[0].exists(y, y == 0)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("com.example.y", 42L)).getVal()) + .isSameAs(True); + + astIss = env.compile("['compre'].exists(y, .y == 'y')"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("y", "y")).getVal()).isSameAs(True); + + astIss = env.compile("[{'z': 0}].exists(y, y.z == 0)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("com.example.y.z", 42L)).getVal()) + .isSameAs(True); + } + + @Test + void bindMacroIntroducesLocalVariable() { + Env env = + newEnv(container("com.example"), declarations(Decls.newVar("com.example.x", Decls.Int))); + + AstIssuesTuple astIss = env.compile("cel.bind(x, 1, x + 1)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat( + env.program(astIss.getAst()) + .eval(mapOf("com.example.x", 42L)) + .getVal() + .equal(IntT.intOf(2))) + .isSameAs(True); + + astIss = env.compile("cel.bind(x, {'y': 0}, x.y == 0)"); + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(mapOf("com.example.x.y", 42L)).getVal()) + .isSameAs(True); + } + + @Test + void blockMacrosIntroduceSequentialLocalVariables() { + Env env = newEnv(macros(Macro.TestOnlyBlockMacros)); + + AstIssuesTuple astIss = + env.compile("cel.block([1, cel.index(0) + 1, cel.index(1) + 1], cel.index(2))"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal().equal(IntT.intOf(3))) + .isSameAs(True); + } + + @Test + void blockMacrosCanNameComprehensionVariables() { + Env env = newEnv(macros(Macro.TestOnlyBlockMacros)); + + AstIssuesTuple astIss = + env.compile( + "[1, 2].map(cel.iterVar(0, 0), " + + "[cel.iterVar(0, 0) + cel.iterVar(0, 0)])[1][0] == 4"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal()).isSameAs(True); + } + + @Test + void twoVariableComprehensionsBindListIndexAndValue() { + Env env = newEnv(); + + AstIssuesTuple astIss = env.compile("[2, 4, 6].transformList(i, v, i + v)[2] == 8"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal()).isSameAs(True); + assertThat( + env.program(astIss.getAst(), evalOptions(OptExhaustiveEval)).eval(emptyMap()).getVal()) + .isSameAs(True); + } + + @Test + void twoVariableComprehensionsBindMapKeyAndValue() { + Env env = newEnv(); + + AstIssuesTuple astIss = + env.compile("{'foo': 'bar'}.transformMap(k, v, k + v)['foo'] == 'foobar'"); + + assertThat(astIss.hasIssues()).isFalse(); + assertThat(env.program(astIss.getAst()).eval(emptyMap()).getVal()).isSameAs(True); + assertThat( + env.program(astIss.getAst(), evalOptions(OptExhaustiveEval)).eval(emptyMap()).getVal()) + .isSameAs(True); + } + @Test void AstToString() { Env stdEnv = newEnv(); diff --git a/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java b/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java index f7bb0e47..e3dea123 100644 --- a/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java +++ b/core/src/test/java/org/projectnessie/cel/checker/CheckerEnvTest.java @@ -17,6 +17,8 @@ import static java.util.Collections.singletonList; import static org.projectnessie.cel.checker.CheckerEnv.newStandardCheckerEnv; +import static org.projectnessie.cel.common.containers.Container.name; +import static org.projectnessie.cel.common.containers.Container.newContainer; import static org.projectnessie.cel.common.types.pb.ProtoTypeRegistry.newRegistry; import com.google.api.expr.v1alpha1.Type; @@ -63,6 +65,21 @@ Overloads.ToDyn, singletonList(paramA), Decls.Dyn, typeParamAList)))) "overlapping overload for name 'dyn' (type '(type_param: \"A\") -> dyn' with overloadId: 'to_dyn' cannot be distinguished from '(type_param: \"A\") -> dyn' with overloadId: 'to_dyn')"); } + @Test + void lexicalIdentifierShadowsContainerQualifiedIdentifier() { + CheckerEnv env = newStandardCheckerEnv(newContainer(name("com.example")), newRegistry()); + env.add(Decls.newVar("com.example.y", Decls.Int)); + env.add(Decls.newVar("y", Decls.Bool)); + + Assertions.assertThat(env.lookupIdent("y").getName()).isEqualTo("com.example.y"); + + env = env.enterScope(); + env.add(Decls.newVar("y", Decls.String)); + + Assertions.assertThat(env.lookupIdent("y").getName()).isEqualTo("y"); + Assertions.assertThat(env.lookupIdent(".com.example.y").getName()).isEqualTo("com.example.y"); + } + @Test void overloadsWithDifferentArityOrStyleDoNotOverlap() { CheckerEnv env = newStandardCheckerEnv(Container.defaultContainer, newRegistry()); diff --git a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java index ad079656..09df7ee1 100644 --- a/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java +++ b/core/src/test/java/org/projectnessie/cel/checker/CheckerTest.java @@ -189,6 +189,56 @@ static TestCase[] checkTestCases() { new TestCase().i("id").r("id~double^id").type(Decls.Double).env(testEnvs.get("default")), new TestCase().i("[]").r("[]~list(dyn)").type(Decls.newListType(Decls.Dyn)), new TestCase().i("[1]").r("[1~int]~list(int)").type(Decls.newListType(Decls.Int)), + new TestCase() + .i("[y, 1]") + .env(new env().idents(Decls.newVar("y", Decls.newWrapperType(Decls.Int)))) + .r("[y~wrapper(int)^y, 1~int]~list(wrapper(int))") + .type(Decls.newListType(Decls.newWrapperType(Decls.Int))), + new TestCase() + .i("[1, y]") + .env(new env().idents(Decls.newVar("y", Decls.newWrapperType(Decls.Int)))) + .r("[1~int, y~wrapper(int)^y]~list(wrapper(int))") + .type(Decls.newListType(Decls.newWrapperType(Decls.Int))), + new TestCase() + .i("[x, null]") + .env(new env().idents(Decls.newVar("x", Decls.newObjectType("test.Message")))) + .r("[x~test.Message^x, null~null]~list(test.Message)") + .type(Decls.newListType(Decls.newObjectType("test.Message"))), + new TestCase() + .i("[null, x]") + .env(new env().idents(Decls.newVar("x", Decls.newObjectType("test.Message")))) + .r("[null~null, x~test.Message^x]~list(test.Message)") + .type(Decls.newListType(Decls.newObjectType("test.Message"))), + new TestCase() + .i("[d, null]") + .env(new env().idents(Decls.newVar("d", Decls.Duration))) + .r("[d~duration^d, null~null]~list(duration)") + .type(Decls.newListType(Decls.Duration)), + new TestCase() + .i("[null, t]") + .env(new env().idents(Decls.newVar("t", Decls.Timestamp))) + .r("[null~null, t~timestamp^t]~list(timestamp)") + .type(Decls.newListType(Decls.Timestamp)), + new TestCase() + .i("tuple(1, dyn(2u), 3.0)") + .env( + new env() + .functions( + Decls.newFunction( + "tuple", + Decls.newOverload( + "tuple_T_U_V", + asList( + Decls.newTypeParamType("T"), + Decls.newTypeParamType("U"), + Decls.newTypeParamType("V")), + Decls.newAbstractType( + "tuple", + asList( + Decls.newTypeParamType("T"), + Decls.newTypeParamType("U"), + Decls.newTypeParamType("V"))))))) + .type(Decls.newAbstractType("tuple", asList(Decls.Int, Decls.Dyn, Decls.Double))), new TestCase() .i("[1, \"A\"]") .r("[1~int, \"A\"~string]~list(dyn)") @@ -269,6 +319,10 @@ static TestCase[] checkTestCases() { + " single_int64 : 2~int\n" + "}~cel.expr.conformance.proto3.TestAllTypes^cel.expr.conformance.proto3.TestAllTypes") .type(Decls.newObjectType("cel.expr.conformance.proto3.TestAllTypes")), + new TestCase() + .i("TestAllTypes{single_bool_wrapper: null}") + .container("cel.expr.conformance.proto3") + .type(Decls.newObjectType("cel.expr.conformance.proto3.TestAllTypes")), new TestCase() .i("TestAllTypes{single_int32: 1u}") .container("cel.expr.conformance.proto3") @@ -1377,7 +1431,7 @@ static TestCase[] checkTestCases() { new TestCase() .i("[].map(x, [].map(y, x in y && y in x))") .error( - "ERROR: :1:33: found no matching overload for '@in' applied to '(type_param: \"_var2\", type_param: \"_var0\")'\n" + "ERROR: :1:33: found no matching overload for '@in' applied to '(list(type_param: \"_var0\"), type_param: \"_var0\")'\n" + " | [].map(x, [].map(y, x in y && y in x))\n" + " | ................................^"), new TestCase() diff --git a/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java b/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java index 48295aea..b0708c52 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/IntTest.java @@ -16,6 +16,7 @@ package org.projectnessie.cel.common.types; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.projectnessie.cel.common.types.BoolT.False; import static org.projectnessie.cel.common.types.BoolT.True; import static org.projectnessie.cel.common.types.DoubleT.DoubleType; @@ -148,6 +149,17 @@ void intConvertToNative_Wrapper() { assertThat(val2).isEqualTo(want2); } + @Test + void intConvertToNative_Int32WrapperRangeError() { + assertThatThrownBy(() -> intOf(1L + Integer.MAX_VALUE).convertToNative(Int32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + + assertThatThrownBy(() -> intOf(-1L + Integer.MIN_VALUE).convertToNative(Int32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + } + @Test void intConvertToType() { assertThat(intOf(-4).convertToType(IntType).equal(intOf(-4))).isSameAs(True); diff --git a/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java b/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java index f409c6f7..bd22dedd 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/MapTest.java @@ -16,6 +16,7 @@ package org.projectnessie.cel.common.types; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.projectnessie.cel.common.types.BoolT.True; import static org.projectnessie.cel.common.types.DoubleT.doubleOf; import static org.projectnessie.cel.common.types.IntT.intOf; @@ -26,6 +27,7 @@ import static org.projectnessie.cel.common.types.pb.ProtoTypeRegistry.newRegistry; import com.google.common.collect.ImmutableMap; +import com.google.protobuf.Struct; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Disabled; @@ -99,6 +101,19 @@ void heterogenousKeys() { ImmutableMap.of(1L, "one", 2L, "two", 3.1d, "three", true, "true", "str", "string")); } + @Test + void mapToProtobufStructRequiresStringKeys() { + MapT stringKeyMap = + (MapT) newWrappedMap(newRegistry(), ImmutableMap.of(stringOf("one"), doubleOf(1.0d))); + assertThat(stringKeyMap.convertToNative(Struct.class).getFieldsMap()).containsOnlyKeys("one"); + + MapT intKeyMap = + (MapT) newWrappedMap(newRegistry(), ImmutableMap.of(intOf(1), stringOf("one"))); + assertThatThrownBy(() -> intKeyMap.convertToNative(Struct.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("bad key type"); + } + // type testStruct struct { // M string // Details []string diff --git a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java index 6396e1f9..ad59033f 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/ProviderTest.java @@ -207,6 +207,122 @@ void typeRegistryNewValue_WrapperFields() { .isEqualTo(123); } + @Test + void typeRegistryNewValue_NullWrapperFieldIsUnset() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", mapOf("single_int32_wrapper", NullValue)); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.hasSingleInt32Wrapper()).isFalse(); + } + + @Test + void typeRegistryNewValue_NullMessageFieldsAreUnset() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", + mapOf( + "single_nested_message", + NullValue, + "single_duration", + NullValue, + "single_timestamp", + NullValue)); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.hasSingleNestedMessage()).isFalse(); + assertThat(ce.hasSingleDuration()).isFalse(); + assertThat(ce.hasSingleTimestamp()).isFalse(); + } + + @Test + void typeRegistryNewValue_RepeatedMessageFieldNullsArePruned() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", + mapOf( + "repeated_timestamp", + newGenericArrayList( + reg, + new Val[] {timestampOf(Instant.ofEpochSecond(1).atZone(ZoneIdZ)), NullValue}), + "repeated_duration", + newGenericArrayList(reg, new Val[] {durationOf(Duration.ofSeconds(1)), NullValue}), + "repeated_int32_wrapper", + newGenericArrayList(reg, new Val[] {intOf(1), NullValue}))); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.getRepeatedTimestampList()) + .containsExactly(Timestamp.newBuilder().setSeconds(1).build()); + assertThat(ce.getRepeatedDurationList()) + .containsExactly(com.google.protobuf.Duration.newBuilder().setSeconds(1).build()); + assertThat(ce.getRepeatedInt32WrapperList()).containsExactly(Int32Value.of(1)); + } + + @Test + void typeRegistryNewValue_MapMessageFieldNullsArePruned() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + Val exp = + reg.newValue( + "cel.expr.conformance.proto3.TestAllTypes", + mapOf( + "map_bool_timestamp", + newMaybeWrappedMap( + reg, + mapOf( + true, + NullValue, + false, + timestampOf(Instant.ofEpochSecond(1).atZone(ZoneIdZ)))), + "map_bool_duration", + newMaybeWrappedMap( + reg, mapOf(true, NullValue, false, durationOf(Duration.ofSeconds(1)))), + "map_bool_int32_wrapper", + newMaybeWrappedMap(reg, mapOf(true, NullValue, false, intOf(1))))); + + assertThat(exp).matches(v -> !Err.isError(v)); + TestAllTypes ce = exp.convertToNative(TestAllTypes.class); + assertThat(ce.getMapBoolTimestampMap()) + .containsExactlyEntriesOf(mapOf(false, Timestamp.newBuilder().setSeconds(1).build())); + assertThat(ce.getMapBoolDurationMap()) + .containsExactlyEntriesOf( + mapOf(false, com.google.protobuf.Duration.newBuilder().setSeconds(1).build())); + assertThat(ce.getMapBoolInt32WrapperMap()) + .containsExactlyEntriesOf(mapOf(false, Int32Value.of(1))); + } + + @Test + void typeRegistryNewValue_InvalidNullFieldAssignmentsReturnErrors() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + String typeName = "cel.expr.conformance.proto3.TestAllTypes"; + + assertThat(reg.newValue(typeName, mapOf("single_bool", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("repeated_int32", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("map_string_string", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("list_value", NullValue))).matches(Err::isError); + assertThat(reg.newValue(typeName, mapOf("single_struct", NullValue))).matches(Err::isError); + } + + @Test + void typeRegistryNewValue_ProtobufStructFieldRequiresStringKeys() { + TypeRegistry reg = newRegistry(TestAllTypes.getDefaultInstance()); + String typeName = "cel.expr.conformance.proto3.TestAllTypes"; + + Val value = + reg.newValue( + typeName, + mapOf("single_struct", newMaybeWrappedMap(reg, mapOf(intOf(1), stringOf("one"))))); + + assertThat(value).matches(Err::isError); + assertThat(value.toString()).contains("invalid value for field 'single_struct': bad key type"); + } + @Test void typeRegistryGetters() { TypeRegistry reg = newRegistry(ParsedExpr.getDefaultInstance()); diff --git a/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java b/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java index 6ef673fa..5a203def 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/StringTest.java @@ -122,7 +122,24 @@ void stringConvertToNative_Wrapper() { @Test void stringConvertToType() { assertThat(stringOf("-1").convertToType(IntType).equal(IntNegOne)).isSameAs(True); + assertThat(stringOf("1").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("t").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("true").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("TRUE").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("True").convertToType(BoolType).equal(True)).isSameAs(True); + assertThat(stringOf("0").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("f").convertToType(BoolType).equal(False)).isSameAs(True); assertThat(stringOf("false").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("FALSE").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("False").convertToType(BoolType).equal(False)).isSameAs(True); + assertThat(stringOf("TrUe").convertToType(BoolType)) + .isInstanceOf(Err.class) + .extracting(Object::toString) + .isEqualTo("type conversion error from 'string' to 'bool'"); + assertThat(stringOf("FaLsE").convertToType(BoolType)) + .isInstanceOf(Err.class) + .extracting(Object::toString) + .isEqualTo("type conversion error from 'string' to 'bool'"); assertThat(stringOf("1").convertToType(UintType).equal(uintOf(1))).isSameAs(True); assertThat(stringOf("2.5").convertToType(DoubleType).equal(doubleOf(2.5))).isSameAs(True); assertThat( diff --git a/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java b/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java index e80ba683..3a1b8a1a 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/TimestampTest.java @@ -34,7 +34,6 @@ import static org.projectnessie.cel.common.types.TypeT.TypeType; import com.google.protobuf.Any; -import com.google.protobuf.StringValue; import com.google.protobuf.Timestamp; import com.google.protobuf.Value; import java.time.DateTimeException; @@ -107,7 +106,12 @@ void timestampConvertToNative_JSON() { // JSON Object val = ts.convertToNative(Value.class); - Object want = StringValue.of("1970-01-01T02:05:06Z"); + Object want = Value.newBuilder().setStringValue("1970-01-01T02:05:06Z").build(); + assertThat(val).isEqualTo(want); + + ts = timestampOf(Instant.ofEpochSecond(253402300799L, 999999999).atZone(ZoneIdZ)); + val = ts.convertToNative(Value.class); + want = Value.newBuilder().setStringValue("9999-12-31T23:59:59.999999999Z").build(); assertThat(val).isEqualTo(want); } diff --git a/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java b/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java index ae658729..e08ca174 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/TypeTest.java @@ -62,4 +62,17 @@ void typeConvertToType() { void typeType() { assertThat(TypeType.type()).isSameAs(TypeType); } + + @Test + void objectTypeEqualsBuiltInTypeWithSameName() { + assertThat(TypeT.newObjectTypeValue("google.protobuf.Timestamp").equal(TimestampType)) + .isSameAs(BoolT.True); + assertThat(TimestampType.equal(TypeT.newObjectTypeValue("google.protobuf.Timestamp"))) + .isSameAs(BoolT.True); + + assertThat(TypeT.newObjectTypeValue("google.protobuf.Duration").equal(DurationType)) + .isSameAs(BoolT.True); + assertThat(DurationType.equal(TypeT.newObjectTypeValue("google.protobuf.Duration"))) + .isSameAs(BoolT.True); + } } diff --git a/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java b/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java index 61e9ce56..5972ded4 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/UintTest.java @@ -109,9 +109,12 @@ void uintConvertToNative_Json() { Value val = uintOf(maxIntJSON).convertToNative(Value.class); assertThat(val).isEqualTo(Value.newBuilder().setNumberValue(9007199254740991.0d).build()); - // Value converts to a JSON decimal string - val = intOf(maxIntJSON + 1).convertToNative(Value.class); + // Value converts to a JSON decimal string. + val = uintOf(maxIntJSON + 1).convertToNative(Value.class); assertThat(val).isEqualTo(Value.newBuilder().setStringValue("9007199254740992").build()); + + val = uintOf(-1L).convertToNative(Value.class); + assertThat(val).isEqualTo(Value.newBuilder().setStringValue("18446744073709551615").build()); } @Test @@ -142,6 +145,17 @@ void uintConvertToNative_Wrapper() { assertThat(val2).isEqualTo(want2); } + @Test + void uintConvertToNative_UInt32WrapperRangeError() { + assertThatThrownBy(() -> uintOf(0x100000000L).convertToNative(UInt32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + + assertThatThrownBy(() -> uintOf(-1L).convertToNative(UInt32Value.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("range error"); + } + @Test void uintConvertToType() { // 18446744073709551612L diff --git a/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java b/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java index 55d30073..01608869 100644 --- a/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java +++ b/core/src/test/java/org/projectnessie/cel/common/types/pb/PbObjectTest.java @@ -28,7 +28,12 @@ import com.google.api.expr.v1alpha1.ParsedExpr; import com.google.api.expr.v1alpha1.SourceInfo; import com.google.protobuf.Any; +import com.google.protobuf.Empty; +import com.google.protobuf.FieldMask; import com.google.protobuf.Message; +import com.google.protobuf.Struct; +import com.google.protobuf.Timestamp; +import com.google.protobuf.Value; import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.Disabled; @@ -90,6 +95,26 @@ void protoObjectConvertToNative() throws Exception { assertThat(unpackedAny).isEqualTo(objVal.value()); } + @Test + void wellKnownProtoObjectsConvertToJsonValue() { + TypeRegistry reg = newRegistry(Empty.getDefaultInstance(), FieldMask.getDefaultInstance()); + + Val empty = reg.nativeToValue(Empty.getDefaultInstance()); + assertThat(empty.convertToNative(Value.class)) + .isEqualTo(Value.newBuilder().setStructValue(Struct.getDefaultInstance()).build()); + + Val fieldMask = + reg.nativeToValue(FieldMask.newBuilder().addPaths("foo").addPaths("bar_baz").build()); + assertThat(fieldMask.convertToNative(Value.class)) + .isEqualTo(Value.newBuilder().setStringValue("foo,barBaz").build()); + + Val timestamp = + reg.nativeToValue( + Timestamp.newBuilder().setSeconds(253402300799L).setNanos(999999999).build()); + assertThat(timestamp.convertToNative(Value.class)) + .isEqualTo(Value.newBuilder().setStringValue("9999-12-31T23:59:59.999999999Z").build()); + } + @Test @Disabled("IMPLEMENT ME") void protoObjectConvertToNative_JSON() { diff --git a/core/src/test/java/org/projectnessie/cel/extension/EncodersLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/EncodersLibTest.java new file mode 100644 index 00000000..b2577568 --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/EncodersLibTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.common.types.BytesT.bytesOf; +import static org.projectnessie.cel.common.types.StringT.stringOf; +import static org.projectnessie.cel.extension.EncodersLib.encoders; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.Program.EvalResult; +import org.projectnessie.cel.common.types.Err; + +class EncodersLibTest { + + @Test + void encodesBase64Bytes() { + assertEvaluates("base64.encode(b'hello')", stringOf("aGVsbG8=")); + } + + @Test + void decodesBase64String() { + assertEvaluates("base64.decode('aGVsbG8=')", bytesOf("hello")); + } + + @Test + void decodesBase64StringWithoutPadding() { + assertEvaluates("base64.decode('aGVsbG8')", bytesOf("hello")); + } + + @Test + void rejectsInvalidBase64String() { + EvalResult result = evaluate("base64.decode('not valid base64')"); + + assertThat(result.getVal()).isInstanceOf(Err.class); + assertThat(result.getVal().toString()).contains("invalid base64 string"); + } + + private static void assertEvaluates(String expression, Object expectedValue) { + assertThat(evaluate(expression).getVal()).isEqualTo(expectedValue); + } + + private static EvalResult evaluate(String expression) { + Env env = newEnv(encoders()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + + Program program = env.program(checked.getAst()); + return program.eval(Map.of()); + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/MathLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/MathLibTest.java new file mode 100644 index 00000000..205a1d4c --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/MathLibTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.common.types.DoubleT.doubleOf; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.UintT.uintOf; +import static org.projectnessie.cel.extension.MathLib.math; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.Program.EvalResult; +import org.projectnessie.cel.common.types.Err; + +class MathLibTest { + + @Test + void findsGreatestScalar() { + assertEvaluates("math.greatest(5.4, 10, 3u, -5.0, 9223372036854775807)", intOf(Long.MAX_VALUE)); + } + + @Test + void findsLeastListElement() { + assertEvaluates("math.least([5.4, 10u, 3u, 1u, 3.5])", uintOf(1)); + } + + @Test + void roundsHalfAwayFromZero() { + assertEvaluates("math.round(-1.5)", doubleOf(-2.0)); + } + + @Test + void shiftsSignedIntsLogicallyRight() { + assertEvaluates("math.bitShiftRight(-1024, 3)", intOf(2305843009213693824L)); + } + + @Test + void rejectsNegativeShiftOffset() { + EvalResult result = evaluate("math.bitShiftLeft(1u, -1)"); + + assertThat(result.getVal()).isInstanceOf(Err.class); + assertThat(result.getVal().toString()).contains("negative offset"); + } + + private static void assertEvaluates(String expression, Object expectedValue) { + assertThat(evaluate(expression).getVal()).isEqualTo(expectedValue); + } + + private static EvalResult evaluate(String expression) { + Env env = newEnv(math()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + + Program program = env.program(checked.getAst()); + return program.eval(Map.of()); + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/NetworkLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/NetworkLibTest.java new file mode 100644 index 00000000..d20f9430 --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/NetworkLibTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.common.types.BoolT.False; +import static org.projectnessie.cel.common.types.BoolT.True; +import static org.projectnessie.cel.common.types.IntT.intOf; +import static org.projectnessie.cel.common.types.StringT.stringOf; +import static org.projectnessie.cel.extension.NetworkLib.network; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.Program; +import org.projectnessie.cel.Program.EvalResult; +import org.projectnessie.cel.common.types.Err; + +class NetworkLibTest { + + @Test + void canonicalizesIpStrings() { + assertEvaluates("string(ip('2001:db8::68'))", stringOf("2001:db8::68")); + } + + @Test + void checksIpProperties() { + assertEvaluates("ip('192.168.0.1').family()", intOf(4)); + assertEvaluates("ip('fe80::1').isLinkLocalUnicast()", True); + } + + @Test + void checksCidrContainment() { + assertEvaluates("cidr('192.168.0.0/24').containsIP('192.168.0.1')", True); + assertEvaluates("cidr('192.168.0.0/24').containsCIDR('192.168.0.0/23')", False); + } + + @Test + void rejectsInvalidIpLiterals() { + EvalResult result = evaluate("ip('192.168.0.1.0')"); + + assertThat(result.getVal()).isInstanceOf(Err.class); + assertThat(result.getVal().toString()).contains("parse error"); + } + + private static void assertEvaluates(String expression, Object expectedValue) { + assertThat(evaluate(expression).getVal()).isEqualTo(expectedValue); + } + + private static EvalResult evaluate(String expression) { + Env env = newEnv(network()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + + Program program = env.program(checked.getAst()); + return program.eval(Map.of()); + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/OptionalLibTest.java b/core/src/test/java/org/projectnessie/cel/extension/OptionalLibTest.java new file mode 100644 index 00000000..9fd7fef2 --- /dev/null +++ b/core/src/test/java/org/projectnessie/cel/extension/OptionalLibTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2026 The Authors of CEL-Java + * + * 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 + * + * http://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 org.projectnessie.cel.extension; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.projectnessie.cel.Env.newEnv; +import static org.projectnessie.cel.extension.OptionalLib.optionals; + +import com.google.api.expr.v1alpha1.Type; +import org.junit.jupiter.api.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.checker.Decls; + +class OptionalLibTest { + + @Test + void declaresOptionalNone() { + assertCheckedType("[optional.none(), optional.of(1)]", Decls.newListType(optional(Decls.Int))); + } + + @Test + void promotesOptionalTypeParameterToDyn() { + assertCheckedType( + "[optional.of(1), optional.of(dyn(1))]", Decls.newListType(optional(Decls.Dyn))); + } + + @Test + void promotesTernaryOptionalTypeParameterToDyn() { + assertCheckedType("true ? optional.of(dyn(1)) : optional.of(1)", optional(Decls.Dyn)); + } + + @Test + void keepsNullableOptionalType() { + assertCheckedType("[optional.of(1), null][0]", optional(Decls.Int)); + } + + private static void assertCheckedType(String expression, Type expectedType) { + Env env = newEnv(optionals()); + Env.AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed.hasIssues()).isFalse(); + + Env.AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked.hasIssues()).isFalse(); + assertThat(checked.getAst().getResultType()).isEqualTo(expectedType); + } + + private static Type optional(Type type) { + return Decls.newAbstractType("optional_type", singletonList(type)); + } +} diff --git a/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java b/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java index d252e2f5..58a38279 100644 --- a/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java +++ b/core/src/test/java/org/projectnessie/cel/extension/StringsTest.java @@ -111,6 +111,12 @@ static Stream testCases() { new TestData("[].join('-') == ''"), new TestData("['x'].join() == 'x'"), new TestData("['x'].join('-') == 'x'"), + new TestData("strings.quote(\"first\\nsecond\") == \"\\\"first\\\\nsecond\\\"\""), + new TestData("strings.quote(\"printable unicode😀\") == \"\\\"printable unicode😀\\\"\""), + new TestData("'Ta©oCαt'.reverse() == 'tαCo©aT'"), + new TestData("\"%d %s %.0f\".format([1, \"two\", 2.5]) == \"1 two 2\""), + new TestData("\"%x\".format([\"Hello world!\"]) == \"48656c6c6f20776f726c6421\""), + new TestData("\"%s\".format([[\"abc\", 3.14, null]]) == \"[abc, 3.14, null]\""), // Error test cases based on checked expression usage. new TestData("'tacocat'.indexOf('a', 30) == -1", "String index out of range: 30"), @@ -175,7 +181,10 @@ static Stream testCases() { new TestData("'hello'.substring(1, 2, 3) == \"\"", "no matching overload", true), new TestData("30.substring(true, 3) == \"\"", "no matching overload", true), new TestData("\"tacocat\".substring(true, 3) == \"\"", "no matching overload", true), - new TestData("\"tacocat\".substring(0, false) == \"\"", "no matching overload", true)); + new TestData("\"tacocat\".substring(0, false) == \"\"", "no matching overload", true), + new TestData( + "\"%a\".format([1])", + "could not parse formatting clause: unrecognized formatting clause \"a\"")); } @Test diff --git a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java index 06a7d0d2..45625645 100644 --- a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java +++ b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTest.java @@ -345,6 +345,10 @@ static TestCase[] testCases() { new TestCase(InterpreterTestCase.map_key_null) .expr("{null:false}[null]") .err("message: unsupported key type"), + new TestCase(InterpreterTestCase.map_key_float) + .expr("{3.3:15.15, 1.0: 5}[1.0]") + .unchecked() + .err("message: unsupported key type"), new TestCase(InterpreterTestCase.map_value_repeat_key_heterogeneous) .expr("{0: 1, 0u: 2}[0.0]") .err("message: Failed with repeated key"), @@ -394,8 +398,12 @@ static TestCase[] testCases() { "TestAllTypes{single_double: double('NaN')} == TestAllTypes{single_double: double('NaN')}") .container("cel.expr.conformance.proto3") .types(dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance()) - // The outcome in the generated Java proto code is different than in the conformance-test, - // it is NOT: "For proto equality, fields with NaN value are treated as not equal." + .out(False), + new TestCase(InterpreterTestCase.ne_proto_nan_not_equal) + .expr( + "TestAllTypes{single_double: double('NaN')} != TestAllTypes{single_double: double('NaN')}") + .container("cel.expr.conformance.proto3") + .types(dev.cel.expr.conformance.proto3.TestAllTypes.getDefaultInstance()) .out(True), new TestCase(InterpreterTestCase.eq_bool_not_null) .expr("google.protobuf.BoolValue{} != null") diff --git a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java index 5c585a21..b45eb5a8 100644 --- a/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java +++ b/core/src/test/java/org/projectnessie/cel/interpreter/InterpreterTestCase.java @@ -72,6 +72,7 @@ public enum InterpreterTestCase { lt_dyn_int_big_uint, lt_dyn_uint_big_double, lt_ne_dyn_int_double, + map_key_float, map_key_mixed_numbers_lossy_double_key, map_key_string_and_int_are_distinct, eq_dyn_string_int, @@ -104,6 +105,7 @@ public enum InterpreterTestCase { not_eq_list_one_element, not_eq_list_one_element2, not_eq_list_mixed_type_numbers, + ne_proto_nan_not_equal, not_int32_eq_uint, not_uint32_eq_double, not_lt_dyn_big_uint_int, diff --git a/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java b/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java index c7de0414..41d23226 100644 --- a/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java +++ b/core/src/test/java/org/projectnessie/cel/parser/ParserTest.java @@ -965,7 +965,7 @@ public static String[][] testCases() { "93", "{\"a\": 1}.\"a\"", "", - "ERROR: :1:10: Syntax error: mismatched input '\"a\"' expecting IDENTIFIER\n" + "ERROR: :1:10: Syntax error: mismatched input '\"a\"' expecting {IDENTIFIER, ESC_IDENTIFIER}\n" + " | {\"a\": 1}.\"a\"\n" + " | .........^", "", @@ -1200,7 +1200,7 @@ public static String[][] testCases() { "122", "func{{a}}", "", - "ERROR: :1:6: Syntax error: extraneous input '{' expecting {'}', ',', IDENTIFIER}\n" + "ERROR: :1:6: Syntax error: extraneous input '{' expecting {'}', ',', IDENTIFIER, ESC_IDENTIFIER}\n" + " | func{{a}}\n" + " | .....^\n" + "ERROR: :1:8: Syntax error: mismatched input '}' expecting ':'\n" @@ -1215,7 +1215,7 @@ public static String[][] testCases() { "123", "msg{:a}", "", - "ERROR: :1:5: Syntax error: extraneous input ':' expecting {'}', ',', IDENTIFIER}\n" + "ERROR: :1:5: Syntax error: extraneous input ':' expecting {'}', ',', IDENTIFIER, ESC_IDENTIFIER}\n" + " | msg{:a}\n" + " | ....^\n" + "ERROR: :1:7: Syntax error: mismatched input '}' expecting ':'\n" diff --git a/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java b/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java index 3f8cf262..6d8a3223 100644 --- a/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java +++ b/core/src/test/java/org/projectnessie/cel/parser/UnescapeTest.java @@ -47,6 +47,12 @@ void unescapeEscapedQuote() { assertThat(text).isEqualTo("\\\""); } + @Test + void unescapeNonBmpUnicodeInEscapedString() { + String text = utf8(unescape("\"\\\"printable unicode😀\\\"\"", false)); + assertThat(text).isEqualTo("\"printable unicode😀\""); + } + @Test void unescapeEscapedEscape() { String text = utf8(unescape("\"\\\\\"", false)); diff --git a/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java b/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java index 4975727c..2a159349 100644 --- a/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java +++ b/core/src/test/java/org/projectnessie/cel/proto/ProtoTest.java @@ -19,16 +19,24 @@ import static java.lang.Boolean.TRUE; import static org.assertj.core.api.Assertions.assertThat; import static org.projectnessie.cel.Env.newCustomEnv; +import static org.projectnessie.cel.Env.newEnv; import static org.projectnessie.cel.EnvOption.declarations; import static org.projectnessie.cel.EnvOption.types; import static org.projectnessie.cel.Library.StdLib; +import static org.projectnessie.cel.Util.mapOf; +import static org.projectnessie.cel.extension.ProtoLib.proto; +import com.google.protobuf.Any; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; +import dev.cel.expr.conformance.proto2.Proto2ExtensionScopedMessage; +import dev.cel.expr.conformance.proto2.TestAllTypes; +import dev.cel.expr.conformance.proto2.TestAllTypesExtensions; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.projectnessie.cel.Ast; @@ -43,6 +51,74 @@ import org.projectnessie.cel.proto.tests.ProtoTestTypes; public class ProtoTest { + @Test + void proto2ExtensionFieldSelection() { + Env env = + newEnv( + types( + TestAllTypes.getDefaultInstance(), + Proto2ExtensionScopedMessage.getDefaultInstance()), + declarations( + Decls.newVar( + "msg", Decls.newObjectType("cel.expr.conformance.proto2.TestAllTypes")))); + TestAllTypes message = + TestAllTypes.newBuilder().setExtension(TestAllTypesExtensions.int32Ext, 42).build(); + + String expression = + "has(msg.`cel.expr.conformance.proto2.int32_ext`) " + + "&& msg.`cel.expr.conformance.proto2.int32_ext` == 42"; + + AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(parsed.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + + AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(checked.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + Val anyValue = env.getTypeAdapter().nativeToValue(Any.pack(message)); + assertThat(env.program(parsed.getAst()).eval(mapOf("msg", anyValue)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + assertThat(env.program(checked.getAst()).eval(mapOf("msg", anyValue)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + } + + @Test + void protoExtensionLibraryFunctions() { + Env env = + newEnv( + proto(), + types( + TestAllTypes.getDefaultInstance(), + Proto2ExtensionScopedMessage.getDefaultInstance()), + declarations( + Decls.newVar( + "msg", Decls.newObjectType("cel.expr.conformance.proto2.TestAllTypes")))); + TestAllTypes message = + TestAllTypes.newBuilder().setExtension(TestAllTypesExtensions.int32Ext, 42).build(); + + String expression = + "proto.hasExt(msg, cel.expr.conformance.proto2.int32_ext) " + + "&& proto.getExt(msg, cel.expr.conformance.proto2.int32_ext) == 42"; + + AstIssuesTuple parsed = env.parse(expression); + assertThat(parsed).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(parsed.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + + AstIssuesTuple checked = env.check(parsed.getAst()); + assertThat(checked).extracting(AstIssuesTuple::hasIssues).isEqualTo(FALSE); + assertThat(env.program(checked.getAst()).eval(mapOf("msg", message)).getVal()) + .extracting(Val::booleanValue) + .isEqualTo(TRUE); + } + @ParameterizedTest @ValueSource( strings = { diff --git a/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 b/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 index 75add0e6..b25a937f 100644 --- a/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 +++ b/generated-antlr/src/main/antlr/org.projectnessie.cel.parser.gen/CEL.g4 @@ -56,7 +56,7 @@ unary member : primary # PrimaryExpr - | member op='.' id=IDENTIFIER (open='(' args=exprList? ')')? # SelectOrCall + | member op='.' id=field (open='(' args=exprList? ')')? # SelectOrCall | member op='[' index=expr ']' # Index | member op='{' entries=fieldInitializerList? ','? '}' # CreateMessage ; @@ -74,7 +74,12 @@ exprList ; fieldInitializerList - : fields+=IDENTIFIER cols+=':' values+=expr (',' fields+=IDENTIFIER cols+=':' values+=expr)* + : fields+=field cols+=':' values+=expr (',' fields+=field cols+=':' values+=expr)* + ; + +field + : IDENTIFIER + | ESC_IDENTIFIER ; mapInitializerList @@ -188,3 +193,5 @@ STRING BYTES : ('b' | 'B') STRING; IDENTIFIER : (LETTER | '_') ( LETTER | DIGIT | '_')*; + +ESC_IDENTIFIER : '`' (ESC_SEQ | ~('\\' | '`' | '\n' | '\r'))* '`';