diff --git a/cel/BUILD.bazel b/cel/BUILD.bazel index 46cb26d62..97b157ba7 100644 --- a/cel/BUILD.bazel +++ b/cel/BUILD.bazel @@ -65,6 +65,7 @@ go_test( "cel_test.go", "decls_test.go", "env_test.go", + "enums_test.go", "fieldpaths_test.go", "folding_test.go", "inlining_test.go", diff --git a/cel/enums_test.go b/cel/enums_test.go new file mode 100644 index 000000000..8b374e519 --- /dev/null +++ b/cel/enums_test.go @@ -0,0 +1,543 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// 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 cel + +import ( + "testing" + + celenv "github.com/google/cel-go/common/env" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + proto2pb "github.com/google/cel-go/test/proto2pb" + proto3pb "github.com/google/cel-go/test/proto3pb" +) + +const ( + enumP2Container = "google.expr.proto2.test" + enumP3Container = "google.expr.proto3.test" + enumP2Global = "google.expr.proto2.test.GlobalEnum" + enumP3Global = "google.expr.proto3.test.GlobalEnum" + enumP2Nested = "google.expr.proto2.test.TestAllTypes.NestedEnum" + enumP3Nested = "google.expr.proto3.test.TestAllTypes.NestedEnum" + strongEnumsFeature = "cel.feature.strong_enums" +) + +func strongEnumEnv(t *testing.T, container string, opts ...EnvOption) *Env { + t.Helper() + conf := celenv.NewConfig("strong enum test config").AddFeatures( + &celenv.Feature{Name: strongEnumsFeature, Enabled: true}, + ) + baseOpts := []EnvOption{ + Container(container), + Types(&proto2pb.TestAllTypes{}), + Types(&proto3pb.TestAllTypes{}), + FromConfig(conf), + } + env, err := NewEnv(append(baseOpts, opts...)...) + if err != nil { + t.Fatalf("NewEnv() failed: %v", err) + } + return env +} + +func legacyEnumEnv(t *testing.T, container string, enumFeature bool) *Env { + t.Helper() + opts := []EnvOption{ + Container(container), + Types(&proto2pb.TestAllTypes{}), + Types(&proto3pb.TestAllTypes{}), + } + if enumFeature { + conf := celenv.NewConfig("legacy enum test config").AddFeatures( + &celenv.Feature{Name: strongEnumsFeature, Enabled: false}, + ) + opts = append(opts, FromConfig(conf)) + } + env, err := NewEnv(opts...) + if err != nil { + t.Fatalf("NewEnv() failed: %v", err) + } + return env +} + +func compileEvalEnum(t *testing.T, env *Env, expr string, vars map[string]any) (ref.Val, error) { + t.Helper() + ast, iss := env.Compile(expr) + if iss.Err() != nil { + t.Fatalf("env.Compile(%q) failed: %v", expr, iss.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("env.Program(%q) failed: %v", expr, err) + } + if vars == nil { + vars = map[string]any{} + } + out, _, err := prg.Eval(vars) + return out, err +} + +func parseEvalEnum(t *testing.T, env *Env, expr string, vars map[string]any) (ref.Val, error) { + t.Helper() + ast, iss := env.Parse(expr) + if iss.Err() != nil { + t.Fatalf("env.Parse(%q) failed: %v", expr, iss.Err()) + } + prg, err := env.Program(ast) + if err != nil { + t.Fatalf("env.Program(%q) failed: %v", expr, err) + } + if vars == nil { + vars = map[string]any{} + } + out, _, err := prg.Eval(vars) + return out, err +} + +// wantEnumValue asserts that expr evaluates to a value of the given enum type +// carrying the given number. The type assertion guarantees the check cannot be +// satisfied by the legacy integer representation. +func wantEnumValue(t *testing.T, env *Env, expr, typeName string, number int64) { + t.Helper() + out, err := compileEvalEnum(t, env, expr, nil) + if err != nil { + t.Fatalf("eval(%q) errored: %v", expr, err) + } + if out.Type().TypeName() != typeName { + t.Errorf("eval(%q) got type %q, want %q", expr, out.Type().TypeName(), typeName) + } + num, err := compileEvalEnum(t, env, "int("+expr+")", nil) + if err != nil { + t.Fatalf("eval(int(%q)) errored: %v", expr, err) + } + iv, ok := num.(types.Int) + if !ok { + t.Fatalf("int(%q) got %T, want types.Int", expr, num) + } + if int64(iv) != number { + t.Errorf("int(%q) got %d, want %d", expr, int64(iv), number) + } +} + +func wantEnumEvalError(t *testing.T, env *Env, expr string) { + t.Helper() + out, err := compileEvalEnum(t, env, expr, nil) + if err == nil { + t.Errorf("eval(%q) got %v, want evaluation error", expr, out) + } +} + +// Enum constant literals produce enum-typed values. + +func TestStrongEnumsLiteralGlobalProto2(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "GlobalEnum.GAZ", enumP2Global, 2) +} + +func TestStrongEnumsLiteralGlobalProto3(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "GlobalEnum.GAZ", enumP3Global, 2) +} + +func TestStrongEnumsLiteralNestedProto2(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "TestAllTypes.NestedEnum.BAR", enumP2Nested, 1) +} + +func TestStrongEnumsLiteralNestedProto3(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "TestAllTypes.NestedEnum.BAR", enumP3Nested, 1) +} + +func TestStrongEnumsLiteralZero(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "GlobalEnum.GOO", enumP2Global, 0) +} + +// type() reports the enum's own type. + +func TestStrongEnumsTypeOfGlobal(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + out, err := compileEvalEnum(t, env, "type(GlobalEnum.GOO)", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + tv, ok := out.(*types.Type) + if !ok { + t.Fatalf("type() got %T, want *types.Type", out) + } + if tv.TypeName() != enumP3Global { + t.Errorf("type() got %q, want %q", tv.TypeName(), enumP3Global) + } +} + +func TestStrongEnumsTypeOfNested(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + out, err := compileEvalEnum(t, env, "type(TestAllTypes.NestedEnum.BAZ)", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + tv, ok := out.(*types.Type) + if !ok { + t.Fatalf("type() got %T, want *types.Type", out) + } + if tv.TypeName() != enumP2Nested { + t.Errorf("type() got %q, want %q", tv.TypeName(), enumP2Nested) + } +} + +func TestStrongEnumsTypeOfFieldDefault(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + out, err := compileEvalEnum(t, env, "type(TestAllTypes{}.standalone_enum)", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + tv, ok := out.(*types.Type) + if !ok { + t.Fatalf("type() got %T, want *types.Type", out) + } + if tv.TypeName() != enumP3Nested { + t.Errorf("type() got %q, want %q", tv.TypeName(), enumP3Nested) + } +} + +// Equality is by enum type and number. Each test also asserts the operands +// are enum-typed so the equality outcome is not trivially the legacy one. + +func TestStrongEnumsEqualitySameValue(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "GlobalEnum.GAR", enumP2Global, 1) + out, err := compileEvalEnum(t, env, "GlobalEnum.GAR == GlobalEnum.GAR", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if b, ok := out.(types.Bool); !ok || !bool(b) { + t.Errorf("same-value equality got %v, want true", out) + } +} + +func TestStrongEnumsEqualityDifferentValue(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "GlobalEnum.GAZ", enumP3Global, 2) + out, err := compileEvalEnum(t, env, "GlobalEnum.GAR == GlobalEnum.GAZ", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if b, ok := out.(types.Bool); !ok || bool(b) { + t.Errorf("different-value equality got %v, want false", out) + } +} + +func TestStrongEnumsEnumNotEqualInt(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + out, err := parseEvalEnum(t, env, "GlobalEnum.GAR == 1", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if b, ok := out.(types.Bool); !ok || bool(b) { + t.Errorf("enum == int got %v, want false", out) + } +} + +func TestStrongEnumsEnumNotEqualOtherEnumType(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + out, err := parseEvalEnum(t, env, "GlobalEnum.GOO == TestAllTypes.NestedEnum.FOO", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if b, ok := out.(types.Bool); !ok || bool(b) { + t.Errorf("cross-enum equality got %v, want false", out) + } +} + +// Field selection yields enum-typed values, including defaults and +// numbers without a declared name. + +func TestStrongEnumsSelectDefault(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "TestAllTypes{}.standalone_enum", enumP2Nested, 0) +} + +func TestStrongEnumsSelectSetValue(t *testing.T) { + env := strongEnumEnv(t, enumP3Container, Variable("x", ObjectType("google.expr.proto3.test.TestAllTypes"))) + msg := &proto3pb.TestAllTypes{StandaloneEnum: proto3pb.TestAllTypes_BAZ} + out, err := compileEvalEnum(t, env, "x.standalone_enum", map[string]any{"x": msg}) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if out.Type().TypeName() != enumP3Nested { + t.Errorf("select got type %q, want %q", out.Type().TypeName(), enumP3Nested) + } +} + +func TestStrongEnumsSelectSetValueProto2(t *testing.T) { + env := strongEnumEnv(t, enumP2Container, Variable("x", ObjectType("google.expr.proto2.test.TestAllTypes"))) + enumVal := proto2pb.TestAllTypes_BAZ + msg := &proto2pb.TestAllTypes{StandaloneEnum: &enumVal} + out, err := compileEvalEnum(t, env, "x.standalone_enum", map[string]any{"x": msg}) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if out.Type().TypeName() != enumP2Nested { + t.Errorf("select got type %q, want %q", out.Type().TypeName(), enumP2Nested) + } +} + +func TestStrongEnumsSelectUnnamedBig(t *testing.T) { + env := strongEnumEnv(t, enumP3Container, Variable("x", ObjectType("google.expr.proto3.test.TestAllTypes"))) + msg := &proto3pb.TestAllTypes{StandaloneEnum: proto3pb.TestAllTypes_NestedEnum(108)} + out, err := compileEvalEnum(t, env, "x.standalone_enum", map[string]any{"x": msg}) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if out.Type().TypeName() != enumP3Nested { + t.Errorf("select got type %q, want %q", out.Type().TypeName(), enumP3Nested) + } + num, err := compileEvalEnum(t, env, "int(x.standalone_enum)", map[string]any{"x": msg}) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if iv, ok := num.(types.Int); !ok || int64(iv) != 108 { + t.Errorf("unnamed select got %v, want 108", num) + } +} + +func TestStrongEnumsSelectUnnamedNeg(t *testing.T) { + env := strongEnumEnv(t, enumP3Container, Variable("x", ObjectType("google.expr.proto3.test.TestAllTypes"))) + msg := &proto3pb.TestAllTypes{StandaloneEnum: proto3pb.TestAllTypes_NestedEnum(-3)} + out, err := compileEvalEnum(t, env, "x.standalone_enum", map[string]any{"x": msg}) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if out.Type().TypeName() != enumP3Nested { + t.Errorf("select got type %q, want %q", out.Type().TypeName(), enumP3Nested) + } + num, err := compileEvalEnum(t, env, "int(x.standalone_enum)", map[string]any{"x": msg}) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if iv, ok := num.(types.Int); !ok || int64(iv) != -3 { + t.Errorf("unnamed select got %v, want -3", num) + } +} + +// Message construction accepts enum-typed values. + +func TestStrongEnumsConstructWithName(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "TestAllTypes{standalone_enum: TestAllTypes.NestedEnum.BAZ}.standalone_enum", enumP2Nested, 2) +} + +func TestStrongEnumsConstructWithIntConversion(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "TestAllTypes{standalone_enum: TestAllTypes.NestedEnum(1)}.standalone_enum", enumP3Nested, 1) + out, err := compileEvalEnum(t, env, "TestAllTypes{standalone_enum: TestAllTypes.NestedEnum(1)}.standalone_enum == TestAllTypes.NestedEnum.BAR", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if b, ok := out.(types.Bool); !ok || !bool(b) { + t.Errorf("constructed field equality got %v, want true", out) + } +} + +func TestStrongEnumsConstructWithUnnamedInt(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "TestAllTypes{standalone_enum: TestAllTypes.NestedEnum(99)}.standalone_enum", enumP3Nested, 99) +} + +func TestStrongEnumsConstructWithNegativeInt(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "TestAllTypes{standalone_enum: TestAllTypes.NestedEnum(-1)}.standalone_enum", enumP3Nested, -1) +} + +// Explicit conversions. + +func TestStrongEnumsIntOfNamedConstant(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "GlobalEnum.GAZ", enumP2Global, 2) +} + +func TestStrongEnumsEnumOfIntInRange(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "TestAllTypes.NestedEnum(2)", enumP2Nested, 2) +} + +func TestStrongEnumsEnumOfIntUnnamedBig(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "TestAllTypes.NestedEnum(20000)", enumP2Nested, 20000) +} + +func TestStrongEnumsEnumOfIntUnnamedNeg(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "GlobalEnum(-33)", enumP3Global, -33) +} + +func TestStrongEnumsEnumOfIntTooBig(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumEvalError(t, env, "TestAllTypes.NestedEnum(5000000000)") +} + +func TestStrongEnumsEnumOfIntTooNeg(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumEvalError(t, env, "TestAllTypes.NestedEnum(-7000000000)") +} + +func TestStrongEnumsEnumOfIntBoundary(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "GlobalEnum(2147483647)", enumP3Global, 2147483647) + wantEnumValue(t, env, "GlobalEnum(-2147483648)", enumP3Global, -2147483648) + wantEnumEvalError(t, env, "GlobalEnum(2147483648)") + wantEnumEvalError(t, env, "GlobalEnum(-2147483649)") +} + +func TestStrongEnumsEnumOfString(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, env, "TestAllTypes.NestedEnum('BAZ')", enumP3Nested, 2) +} + +func TestStrongEnumsEnumOfStringProto2(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, env, "GlobalEnum('GAR')", enumP2Global, 1) +} + +func TestStrongEnumsEnumOfStringUnknown(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + wantEnumEvalError(t, env, "TestAllTypes.NestedEnum('BLETCH')") +} + +// Runtime behavior holds for parse-only programs as well. + +func TestStrongEnumsParseOnlyConversion(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + out, err := parseEvalEnum(t, env, "TestAllTypes.NestedEnum(2)", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if out.Type().TypeName() != enumP3Nested { + t.Errorf("parse-only conversion got type %q, want %q", out.Type().TypeName(), enumP3Nested) + } +} + +func TestStrongEnumsParseOnlyLiteral(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + out, err := parseEvalEnum(t, env, "GlobalEnum.GAZ", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if out.Type().TypeName() != enumP2Global { + t.Errorf("parse-only literal got type %q, want %q", out.Type().TypeName(), enumP2Global) + } +} + +// The mode is opt-in: enabling it changes enum typing, while leaving it out +// or disabling it keeps the legacy integer behavior. + +func TestStrongEnumsDefaultRemainsLegacy(t *testing.T) { + strong := strongEnumEnv(t, enumP2Container) + wantEnumValue(t, strong, "GlobalEnum.GAZ", enumP2Global, 2) + + legacy := legacyEnumEnv(t, enumP2Container, false) + out, err := compileEvalEnum(t, legacy, "GlobalEnum.GAZ", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if iv, ok := out.(types.Int); !ok || int64(iv) != 2 { + t.Errorf("legacy literal got %v (%T), want int 2", out, out) + } + out, err = compileEvalEnum(t, legacy, "GlobalEnum.GAZ == 2", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if b, ok := out.(types.Bool); !ok || !bool(b) { + t.Errorf("legacy equality with int got %v, want true", out) + } +} + +func TestStrongEnumsDisabledRemainsLegacy(t *testing.T) { + strong := strongEnumEnv(t, enumP3Container) + wantEnumValue(t, strong, "TestAllTypes{}.standalone_enum", enumP3Nested, 0) + + disabled := legacyEnumEnv(t, enumP3Container, true) + out, err := compileEvalEnum(t, disabled, "TestAllTypes{}.standalone_enum", nil) + if err != nil { + t.Fatalf("eval errored: %v", err) + } + if iv, ok := out.(types.Int); !ok || int64(iv) != 0 { + t.Errorf("disabled select got %v (%T), want int 0", out, out) + } +} + +// Checked compilation agrees with the runtime types. + +func TestStrongEnumsCheckerConstantType(t *testing.T) { + env := strongEnumEnv(t, enumP2Container) + ast, iss := env.Compile("GlobalEnum.GAZ") + if iss.Err() != nil { + t.Fatalf("Compile failed: %v", iss.Err()) + } + if ast.OutputType().TypeName() != enumP2Global { + t.Errorf("checked output type got %q, want %q", ast.OutputType().TypeName(), enumP2Global) + } +} + +func TestStrongEnumsCheckerFieldSelectType(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + ast, iss := env.Compile("TestAllTypes{}.standalone_enum") + if iss.Err() != nil { + t.Fatalf("Compile failed: %v", iss.Err()) + } + if ast.OutputType().TypeName() != enumP3Nested { + t.Errorf("checked output type got %q, want %q", ast.OutputType().TypeName(), enumP3Nested) + } +} + +func TestStrongEnumsCheckerConversionType(t *testing.T) { + env := strongEnumEnv(t, enumP3Container) + ast, iss := env.Compile("TestAllTypes.NestedEnum('BAZ')") + if iss.Err() != nil { + t.Fatalf("Compile failed: %v", iss.Err()) + } + if ast.OutputType().TypeName() != enumP3Nested { + t.Errorf("checked output type got %q, want %q", ast.OutputType().TypeName(), enumP3Nested) + } + ast, iss = env.Compile("int(GlobalEnum.GOO)") + if iss.Err() != nil { + t.Fatalf("Compile failed: %v", iss.Err()) + } + if ast.OutputType() != IntType { + t.Errorf("checked int(enum) type got %v, want int", ast.OutputType()) + } +} + +func TestStrongEnumsLegacyCheckerUnchanged(t *testing.T) { + strong := strongEnumEnv(t, enumP2Container) + sast, siss := strong.Compile("GlobalEnum.GAZ") + if siss.Err() != nil { + t.Fatalf("Compile failed: %v", siss.Err()) + } + if sast.OutputType().TypeName() != enumP2Global { + t.Errorf("strong checked type got %q, want %q", sast.OutputType().TypeName(), enumP2Global) + } + + legacy := legacyEnumEnv(t, enumP2Container, false) + ast, iss := legacy.Compile("GlobalEnum.GAZ") + if iss.Err() != nil { + t.Fatalf("Compile failed: %v", iss.Err()) + } + if ast.OutputType() != IntType { + t.Errorf("legacy checked type got %v, want int", ast.OutputType()) + } +} diff --git a/cel/env.go b/cel/env.go index b226e2a86..492b3ca7a 100644 --- a/cel/env.go +++ b/cel/env.go @@ -893,6 +893,21 @@ func (e *Env) configure(opts []EnvOption) (*Env, error) { } } + // Enable strong enum typing if using a proto-based *types.Registry + if e.HasFeature(featureStrongEnums) { + reg, isReg := e.provider.(*types.Registry) + if !isReg { + return nil, fmt.Errorf("StrongEnums() option is only compatible with *types.Registry providers") + } + reg.WithStrongEnums(true) + for _, opt := range strongEnumOptions(reg) { + e, err = opt(e) + if err != nil { + return nil, err + } + } + } + // Ensure that the checker init happens eagerly rather than lazily. if e.HasFeature(featureEagerlyValidateDeclarations) { _, err := e.initChecker() @@ -904,6 +919,63 @@ func (e *Env) configure(opts []EnvOption) (*Env, error) { return e, nil } +// strongEnumOptions produces conversion function declarations for the enum +// types registered with the registry. Each enum type is callable as a +// conversion function accepting an int or a string, and the standard int +// conversion accepts values of the enum type. +func strongEnumOptions(reg *types.Registry) []EnvOption { + enumDescs := reg.EnumDescriptors() + opts := make([]EnvOption, 0, len(enumDescs)*2) + for _, ed := range enumDescs { + enumName := string(ed.FullName()) + enumType := types.NewOpaqueType(enumName) + overloadPrefix := strings.ReplaceAll(enumName, ".", "_") + opts = append(opts, + Function(enumName, + Overload(overloadPrefix+"_int_to_enum", []*Type{IntType}, enumType, + UnaryBinding(intToEnumBinding(enumName))), + Overload(overloadPrefix+"_string_to_enum", []*Type{StringType}, enumType, + UnaryBinding(stringToEnumBinding(enumName, ed.Values())))), + Function("int", + Overload(overloadPrefix+"_to_int", []*Type{enumType}, IntType, + UnaryBinding(func(val ref.Val) ref.Val { + return val.ConvertToType(types.IntType) + })))) + } + return opts +} + +// intToEnumBinding converts an int value to the given enum type, failing for +// values outside the signed 32-bit range. +func intToEnumBinding(enumName string) func(ref.Val) ref.Val { + return func(val ref.Val) ref.Val { + i, ok := val.(types.Int) + if !ok { + return types.MaybeNoSuchOverloadErr(val) + } + if int64(i) > math.MaxInt32 || int64(i) < math.MinInt32 { + return types.NewErr("enum value out of int32 range: %d", int64(i)) + } + return types.NewEnumValue(enumName, int32(i)) + } +} + +// stringToEnumBinding converts a string value naming one of the enum's values +// to the given enum type, failing for names the enum does not declare. +func stringToEnumBinding(enumName string, values protoreflect.EnumValueDescriptors) func(ref.Val) ref.Val { + return func(val ref.Val) ref.Val { + s, ok := val.(types.String) + if !ok { + return types.MaybeNoSuchOverloadErr(val) + } + vd := values.ByName(protoreflect.Name(string(s))) + if vd == nil { + return types.NewErr("invalid enum value name '%s' for enum '%s'", string(s), enumName) + } + return types.NewEnumValue(enumName, int32(vd.Number())) + } +} + func (e *Env) initChecker() (*checker.Env, error) { e.chkOnce.Do(func() { chkOpts := []checker.Option{} diff --git a/cel/options.go b/cel/options.go index 054008d74..7bf7b672a 100644 --- a/cel/options.go +++ b/cel/options.go @@ -74,6 +74,10 @@ const ( // Enable accessing fields by JSON names within protobuf messages featureJSONFieldNames + + // Enable strong typing of protobuf enum values so they are treated as + // distinct enum types rather than plain integers. + featureStrongEnums ) var featureIDsToNames = map[int]string{ @@ -81,6 +85,7 @@ var featureIDsToNames = map[int]string{ featureCrossTypeNumericComparisons: "cel.feature.cross_type_numeric_comparisons", featureIdentEscapeSyntax: "cel.feature.backtick_escape_syntax", featureJSONFieldNames: "cel.feature.json_field_names", + featureStrongEnums: "cel.feature.strong_enums", } func featureNameByID(id int) (string, bool) { @@ -916,6 +921,16 @@ func CrossTypeNumericComparisons(enabled bool) EnvOption { return features(featureCrossTypeNumericComparisons, enabled) } +// StrongEnums enables strong typing of protobuf enum values within CEL +// programs. When enabled, enum constants, enum-typed message fields, and enum +// conversion calls produce values whose type is the fully qualified enum type +// rather than int, and each registered enum type is callable as a conversion +// function from int and string values. The legacy integer treatment of enums +// remains the default when the option is disabled or absent. +func StrongEnums(enabled bool) EnvOption { + return features(featureStrongEnums, enabled) +} + // DefaultUTCTimeZone ensures that time-based operations use the UTC timezone rather than the // input time's local timezone. func DefaultUTCTimeZone(enabled bool) EnvOption { diff --git a/checker/env.go b/checker/env.go index 477918c48..6a6c42cc5 100644 --- a/checker/env.go +++ b/checker/env.go @@ -228,6 +228,9 @@ func (e *Env) lookupGlobalIdent(candidate string) *decls.VariableDecl { // Next try to import this as an enum value by splitting the name in a type prefix and // the enum inside. if enumValue := e.provider.EnumValue(candidate); enumValue.Type() != types.ErrType { + if enumType, ok := enumValue.Type().(*types.Type); ok { + return decls.NewConstant(candidate, enumType, enumValue) + } return decls.NewConstant(candidate, types.IntType, enumValue) } return nil diff --git a/common/types/BUILD.bazel b/common/types/BUILD.bazel index 37d4df495..431dcdac5 100644 --- a/common/types/BUILD.bazel +++ b/common/types/BUILD.bazel @@ -14,6 +14,7 @@ go_library( "compare.go", "double.go", "duration.go", + "enum.go", "err.go", "int.go", "iterator.go", diff --git a/common/types/enum.go b/common/types/enum.go new file mode 100644 index 000000000..1ee9ecbdf --- /dev/null +++ b/common/types/enum.go @@ -0,0 +1,97 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// 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 types + +import ( + "fmt" + "reflect" + + "github.com/google/cel-go/common/types/ref" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// Enum represents a protobuf enum value as a first-class CEL value with its +// own type identity, used when strong enum handling is enabled on the +// registry. The value tracks the fully qualified enum type name along with +// the numeric value of the enum. +type Enum struct { + celType *Type + value int32 +} + +// NewEnumValue creates an Enum value from a fully qualified enum type name +// and the numeric value of the enum. +func NewEnumValue(typeName string, value int32) Enum { + return Enum{ + celType: NewOpaqueType(typeName), + value: value, + } +} + +// ConvertToNative implements ref.Val.ConvertToNative. +func (e Enum) ConvertToNative(typeDesc reflect.Type) (any, error) { + switch typeDesc.Kind() { + case reflect.Int32: + if typeDesc == reflect.TypeOf(protoreflect.EnumNumber(0)) { + return protoreflect.EnumNumber(e.value), nil + } + return reflect.ValueOf(e.value).Convert(typeDesc).Interface(), nil + case reflect.Int64: + return reflect.ValueOf(int64(e.value)).Convert(typeDesc).Interface(), nil + case reflect.Interface: + ev := e.Value() + if reflect.TypeOf(ev).Implements(typeDesc) { + return ev, nil + } + if reflect.TypeOf(e).Implements(typeDesc) { + return e, nil + } + } + return nil, fmt.Errorf("type conversion error from '%s' to '%v'", e.celType.TypeName(), typeDesc) +} + +// ConvertToType implements ref.Val.ConvertToType. +func (e Enum) ConvertToType(typeVal ref.Type) ref.Val { + switch typeVal { + case IntType: + return Int(e.value) + case TypeType: + return e.celType + } + if typeVal.TypeName() == e.celType.TypeName() { + return e + } + return NewErr("type conversion error from '%s' to '%s'", e.celType.TypeName(), typeVal.TypeName()) +} + +// Equal implements ref.Val.Equal. +func (e Enum) Equal(other ref.Val) ref.Val { + o, ok := other.(Enum) + if !ok { + return False + } + return Bool(e.celType.TypeName() == o.celType.TypeName() && e.value == o.value) +} + +// Type implements ref.Val.Type. +func (e Enum) Type() ref.Type { + return e.celType +} + +// Value implements ref.Val.Value. +func (e Enum) Value() any { + return int64(e.value) +} diff --git a/common/types/object.go b/common/types/object.go index bb2a09e87..e6d84217f 100644 --- a/common/types/object.go +++ b/common/types/object.go @@ -156,6 +156,11 @@ func (o *protoObj) Get(index ref.Val) ref.Val { if err != nil { return NewErrFromString(err.Error()) } + if reg, ok := o.Adapter.(*Registry); ok && reg.StrongEnums() && fd.IsEnum() { + if enumNum, isInt := fv.(int64); isInt { + return NewEnumValue(string(fd.Descriptor().Enum().FullName()), int32(enumNum)) + } + } return o.NativeToValue(fv) } diff --git a/common/types/pb/enum.go b/common/types/pb/enum.go index 09a154630..f0ba76e70 100644 --- a/common/types/pb/enum.go +++ b/common/types/pb/enum.go @@ -42,3 +42,9 @@ func (ed *EnumValueDescription) Name() string { func (ed *EnumValueDescription) Value() int32 { return int32(ed.desc.Number()) } + +// Descriptor returns the protoreflect.EnumValueDescriptor backing the enum +// value description. +func (ed *EnumValueDescription) Descriptor() protoreflect.EnumValueDescriptor { + return ed.desc +} diff --git a/common/types/provider.go b/common/types/provider.go index 1bb2c11ed..5a3c57005 100644 --- a/common/types/provider.go +++ b/common/types/provider.go @@ -88,8 +88,9 @@ type FieldType struct { // Registry provides type information for a set of registered types. type Registry struct { - revTypeMap map[string]*Type - pbdb *pb.Db + revTypeMap map[string]*Type + pbdb *pb.Db + strongEnums bool } // NewRegistry accepts a list of proto message instances and returns a type @@ -172,8 +173,9 @@ func NewEmptyRegistry() *Registry { // Copy copies the current state of the registry into its own memory space. func (p *Registry) Copy() *Registry { copy := &Registry{ - revTypeMap: make(map[string]*Type), - pbdb: p.pbdb.Copy(), + revTypeMap: make(map[string]*Type), + pbdb: p.pbdb.Copy(), + strongEnums: p.strongEnums, } for k, v := range p.revTypeMap { copy.revTypeMap[k] = v @@ -209,9 +211,48 @@ func (p *Registry) EnumValue(enumName string) ref.Val { if !found { return NewErr("unknown enum name '%s'", enumName) } + if p.strongEnums { + return NewEnumValue(string(enumVal.Descriptor().Parent().FullName()), enumVal.Value()) + } return Int(enumVal.Value()) } +// StrongEnums returns whether strong enum handling is enabled in this registry. +func (p *Registry) StrongEnums() bool { + return p.strongEnums +} + +// WithStrongEnums configures the registry to treat protobuf enum values as +// first-class enum-typed values rather than plain integers. +func (p *Registry) WithStrongEnums(enabled bool) { + p.strongEnums = enabled +} + +// EnumDescriptors returns the distinct enum type descriptors registered with +// the registry. +func (p *Registry) EnumDescriptors() []protoreflect.EnumDescriptor { + seen := make(map[protoreflect.FullName]bool) + var descs []protoreflect.EnumDescriptor + for _, fd := range p.pbdb.FileDescriptions() { + for _, enumValName := range fd.GetEnumNames() { + enumVal, found := p.pbdb.DescribeEnum(enumValName) + if !found { + continue + } + enumDesc, ok := enumVal.Descriptor().Parent().(protoreflect.EnumDescriptor) + if !ok { + continue + } + if seen[enumDesc.FullName()] { + continue + } + seen[enumDesc.FullName()] = true + descs = append(descs, enumDesc) + } + } + return descs +} + // FindFieldType returns the field type for a checked type value. Returns false if // the field could not be found. // @@ -261,10 +302,24 @@ func (p *Registry) FindStructFieldType(structType, fieldName string) (*FieldType if !found { return nil, false } + getFrom := field.GetFrom + if p.strongEnums && field.IsEnum() { + enumName := string(field.Descriptor().Enum().FullName()) + getFrom = func(target any) (any, error) { + v, err := field.GetFrom(target) + if err != nil { + return nil, err + } + if enumNum, ok := v.(int64); ok { + return NewEnumValue(enumName, int32(enumNum)), nil + } + return v, nil + } + } return &FieldType{ - Type: fieldDescToCELType(field), + Type: fieldDescToCELType(field, p.strongEnums), IsSet: field.IsSet, - GetFrom: field.GetFrom, + GetFrom: getFrom, IsJSONField: p.pbdb.JSONFieldNames() && fieldName == field.JSONName(), }, true } @@ -289,6 +344,9 @@ func (p *Registry) FindIdent(identName string) (ref.Val, bool) { return t, true } if enumVal, found := p.pbdb.DescribeEnum(identName); found { + if p.strongEnums { + return NewEnumValue(string(enumVal.Descriptor().Parent().FullName()), enumVal.Value()), true + } return Int(enumVal.Value()), true } return nil, false @@ -454,23 +512,26 @@ func (p *Registry) registerAllTypes(fd *pb.FileDescription) error { return nil } -func fieldDescToCELType(field *pb.FieldDescription) *Type { +func fieldDescToCELType(field *pb.FieldDescription, strongEnums bool) *Type { if field.IsMap() { return NewMapType( - singularFieldDescToCELType(field.KeyType), - singularFieldDescToCELType(field.ValueType)) + singularFieldDescToCELType(field.KeyType, strongEnums), + singularFieldDescToCELType(field.ValueType, strongEnums)) } if field.IsList() { - return NewListType(singularFieldDescToCELType(field)) + return NewListType(singularFieldDescToCELType(field, strongEnums)) } - return singularFieldDescToCELType(field) + return singularFieldDescToCELType(field, strongEnums) } -func singularFieldDescToCELType(field *pb.FieldDescription) *Type { +func singularFieldDescToCELType(field *pb.FieldDescription, strongEnums bool) *Type { if field.IsMessage() { return NewObjectType(string(field.Descriptor().Message().FullName())) } if field.IsEnum() { + if strongEnums { + return NewOpaqueType(string(field.Descriptor().Enum().FullName())) + } return IntType } return ProtoCELPrimitives[field.ProtoKind()]