Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ clean: ## Delete intermediate build artifacts
git clean -Xdf

.PHONY: test
test: ## Run all unit tests
test: ## Run all unit tests with and without native rules
$(GO) test -race -cover ./...

.PHONY: test-opaque
Expand All @@ -59,6 +59,7 @@ lint-fix:
.PHONY: conformance
conformance: $(BIN)/protovalidate-conformance protovalidate-conformance-go ## Run conformance tests
$(BIN)/protovalidate-conformance $(ARGS) $(BIN)/protovalidate-conformance-go --expected_failures=conformance/expected_failures.yaml
DISABLE_NATIVE_RULES=true $(BIN)/protovalidate-conformance $(ARGS) $(BIN)/protovalidate-conformance-go --expected_failures=conformance/expected_failures.yaml

.PHONY: conformance-hyperpb
conformance-hyperpb: ## Run conformance tests against hyperpb
Expand Down Expand Up @@ -98,6 +99,9 @@ bench: $(BENCH_TMP)
-count $(BENCH_COUNT) \
| tee "$(BENCH_TMP)/$(BENCH_NAME).bench.txt"

.PHONY: bench-cel
bench-cel: $(BENCH_TMP)
DISABLE_NATIVE_RULES=true $(MAKE) bench

.PHONY: upgrade-go
upgrade-go:
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ Highlights for Go developers include:

API documentation for Go is available on [pkg.go.dev][pkg-go].

### Native standard validation rules
protovalidate-go provides native support for standard validation rule processing. They are enabled by default and are disabled by setting the ValidatorOption `WithDisableNativeRules`.

We continue to validate that the native rules and the CEL rules produce identical results. The `compliance` Makefile target has been updated to run twice,
once with the native rules enabled, and once with the CEL rules enabled.

## Additional languages and repositories

Protovalidate isn't just for Go! You might be interested in sibling repositories for other languages:
Expand Down
79 changes: 79 additions & 0 deletions base.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"slices"

"buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)

Expand Down Expand Up @@ -60,6 +61,84 @@ func (b *base) rulePath(suffix *validate.FieldPath) *validate.FieldPath {
return prefixRulePath(b.RulePrefix, suffix)
}

// ruleSite is a compile-time pre-built bundle for a single rule site: the
// 2-element rule path suffix and the leaf descriptor. Using ruleSite with
// newViolationAt avoids re-allocating FieldPathElement proto messages on
// every violation (each FieldPathElement rebuild allocates ~4 sub-objects).
type ruleSite struct {
// pathElements is the rule path suffix, e.g.
// [FieldRules.int32 element, Int32Rules.gt element].
//
// Safe to share across violations: only the containing *FieldPath is
// rebuilt per violation, which is what updateViolationPaths mutates.
pathElements []*validate.FieldPathElement
// desc is the leaf rule field descriptor (e.g. Int32Rules.gt); it is
// stored on the returned *Violation's RuleDescriptor field.
desc protoreflect.FieldDescriptor

// if there are constant values for ruleID or message, specify them once so they can be reused
ruleID *string
message *string
}

// makeRuleSite pre-builds a ruleSite for a rule at compile time.
// if the ruleID or message are NOT constant, pass in an empty string and supply it when calling newViolation.
func makeRuleSite(ruleDesc, desc protoreflect.FieldDescriptor, ruleID string, message string) ruleSite {
var ruleIDPtr *string
if ruleID != "" {
ruleIDPtr = proto.String(ruleID)
}
var messagePtr *string
if message != "" {
messagePtr = proto.String(message)
}
return ruleSite{
pathElements: []*validate.FieldPathElement{
fieldPathElement(ruleDesc),
fieldPathElement(desc),
},
desc: desc,
ruleID: ruleIDPtr,
message: messagePtr,
}
}

// newViolation constructs a Violation.
// ruleDesc is the top-level rule descriptor (e.g., FieldRules.int32),
// desc is the specific constraint descriptor (e.g., Int32Rules.gt).
//
// it uses pre-built path elements in site instead of rebuilding them each call.
func (b *base) newViolation(
site ruleSite,
ruleID string,
message string,
fieldValue protoreflect.Value,
ruleValue protoreflect.Value,
) *Violation {
ruleIDPtr := site.ruleID
if ruleIDPtr == nil {
ruleIDPtr = proto.String(ruleID)
}
messagePtr := site.message
if messagePtr == nil {
messagePtr = proto.String(message)
}
return &Violation{
Proto: validate.Violation_builder{
Field: b.fieldPath(),
Rule: b.rulePath(validate.FieldPath_builder{
Elements: site.pathElements,
}.Build()),
RuleId: ruleIDPtr,
Message: messagePtr,
}.Build(),
FieldValue: fieldValue,
FieldDescriptor: b.Descriptor,
RuleValue: ruleValue,
RuleDescriptor: site.desc,
}
}

func prefixRulePath(prefix *validate.FieldPath, suffix *validate.FieldPath) *validate.FieldPath {
if len(prefix.GetElements()) > 0 {
return validate.FieldPath_builder{
Expand Down
99 changes: 99 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type builder struct {
extensionTypeResolver protoregistry.ExtensionTypeResolver
allowUnknownFields bool
Load func(desc protoreflect.MessageDescriptor) messageEvaluator
disableNativeRules bool
}

// newBuilder initializes a new Builder.
Expand All @@ -57,13 +58,15 @@ func newBuilder(
disableLazy bool,
extensionTypeResolver protoregistry.ExtensionTypeResolver,
allowUnknownFields bool,
disableNativeRules bool,
seedDesc ...protoreflect.MessageDescriptor,
) *builder {
bldr := &builder{
env: env,
rules: newCache(),
extensionTypeResolver: extensionTypeResolver,
allowUnknownFields: allowUnknownFields,
disableNativeRules: disableNativeRules,
}

if disableLazy {
Expand Down Expand Up @@ -464,6 +467,37 @@ func (bldr *builder) processStandardRules(
}
}

// make a copy of the rules because we're going to clear anything handled by native rules, leaving
// anything else to be handled by CEL rules. this allows us to fall back to CEL rules if there is no
// native rule (for example, when a new rule is added to the validate proto, but the native code hasn't
// been updated. we are making a copy because we don't want to modify the original rules in case they are
// reused (happens in some test cases, could happen in production code with dynamic messages).
rules = proto.CloneOf[*validate.FieldRules](rules)

// put behind a feature flag to allow for testing.
// it's easier to follow like this, don't break it up
//nolint:nestif
if !bldr.disableNativeRules {
// Try native Go evaluators for repeated list-level rules (min_items, max_items, unique).
if fdesc.IsList() && valEval.NestedRule == nil {
if native := tryNativeRepeatedRules(newBase(valEval), rules.GetRepeated()); native != nil {
valEval.Append(native)
}
}
// Try native Go evaluators for map-level rules (min_pairs, max_pairs).
if fdesc.IsMap() && valEval.NestedRule == nil {
if native := tryNativeMapRules(newBase(valEval), rules.GetMap()); native != nil {
valEval.Append(native)
}
}
// Try native Go evaluators for known simple rules before falling back to CEL.
if !fdesc.IsMap() && !fdesc.IsList() {
if native := bldr.tryNativeRules(fdesc, rules, valEval); native != nil {
valEval.Append(native)
}
}
}

stdRules, err := bldr.rules.Build(
bldr.env,
fdesc,
Expand All @@ -482,6 +516,71 @@ func (bldr *builder) processStandardRules(
return nil
}

func (bldr *builder) tryNativeRules(
fdesc protoreflect.FieldDescriptor,
rules *validate.FieldRules,
valEval *value,
) evaluator {
if rules == nil {
return nil
}
base := newBase(valEval)
var native evaluator
switch fdesc.Kind() {
case protoreflect.Int32Kind:
native = tryBuildNativeInt32Rules(base, rules.GetInt32())
case protoreflect.Sint32Kind:
native = tryBuildNativeSint32Rules(base, rules.GetSint32())
case protoreflect.Sfixed32Kind:
native = tryBuildNativeSfixed32Rules(base, rules.GetSfixed32())
case protoreflect.Int64Kind:
native = tryBuildNativeInt64Rules(base, rules.GetInt64())
case protoreflect.Sint64Kind:
native = tryBuildNativeSint64Rules(base, rules.GetSint64())
case protoreflect.Sfixed64Kind:
native = tryBuildNativeSfixed64Rules(base, rules.GetSfixed64())
case protoreflect.Uint32Kind:
native = tryBuildNativeUint32Rules(base, rules.GetUint32())
case protoreflect.Fixed32Kind:
native = tryBuildNativeFixed32Rules(base, rules.GetFixed32())
case protoreflect.Uint64Kind:
native = tryBuildNativeUint64Rules(base, rules.GetUint64())
case protoreflect.Fixed64Kind:
native = tryBuildNativeFixed64Rules(base, rules.GetFixed64())
case protoreflect.FloatKind:
native = tryBuildNativeFloatRules(base, rules.GetFloat())
case protoreflect.DoubleKind:
native = tryBuildNativeDoubleRules(base, rules.GetDouble())
case protoreflect.StringKind:
native = tryBuildNativeStringRules(base, rules.GetString())
case protoreflect.BoolKind:
native = tryBuildNativeBoolRules(base, rules.GetBool())
case protoreflect.EnumKind:
native = tryBuildNativeEnumRules(base, rules.GetEnum())
case protoreflect.BytesKind:
native = tryBuildNativeBytesRules(base, rules.GetBytes())
default:
return nil
}
if native == nil {
return nil
}
// processWrapperRules swaps in the inner "value" field as fdesc when
// building rules for a wrapper WKT (Int32Value, StringValue, ...), but
// leaves valEval.Descriptor pointing at the outer wrapper message field.
// Detect that here and wrap the native eval so it unwraps the wrapper
// message at runtime before calling val.Int()/Bytes()/etc.
if valEval.Descriptor != nil &&
(valEval.Descriptor.Kind() == protoreflect.MessageKind ||
valEval.Descriptor.Kind() == protoreflect.GroupKind) {
native = wrappedValueEval{
innerField: fdesc,
inner: native,
}
}
return native
}

func (bldr *builder) processAnyRules(
fdesc protoreflect.FieldDescriptor,
fieldRules *validate.FieldRules,
Expand Down
2 changes: 1 addition & 1 deletion builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestBuildCache(t *testing.T) {
env, err := cel.NewEnv(cel.Lib(pvcel.NewLibrary()))
require.NoError(t, err, "failed to construct CEL environment")
bldr := newBuilder(
env, false, protoregistry.GlobalTypes, false,
env, false, protoregistry.GlobalTypes, false, false,
)
wg := sync.WaitGroup{}
for i := range 100 {
Expand Down
Loading
Loading