From d9138ec55217970741440815943a93e31f6947fd Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 13:50:43 -0400 Subject: [PATCH 01/31] Add benchmarks mirroring protovalidate-go for native-rules port baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the JMH measurement substrate for the native-rules port. Each subsequent phase will compare against these numbers to verify the port delivers real CPU and allocation wins. Ports the bench and test messages from protovalidate-go's bench.proto and native_test.proto into a new native_bench.proto, with gofakeit annotations stripped (no maintained Java equivalent of protogofakeit). Hand-built deterministic fixtures replace the gofakeit-generated values, so runs are reproducible. Adds compile-time benchmarks alongside the steady-state ones to track evaluator-construction cost — under the planned clone-and-clear dispatch, CEL will skip compilation for rules covered natively. --- .../benchmarks/BenchFixtures.java | 203 ++++++++++++++++++ .../benchmarks/EvaluatorBuildBenchmark.java | 66 ++++++ .../benchmarks/ValidationBenchmark.java | 139 +++++++++++- .../src/jmh/proto/bench/v1/native_bench.proto | 200 +++++++++++++++++ 4 files changed, 605 insertions(+), 3 deletions(-) create mode 100644 benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java create mode 100644 benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java create mode 100644 benchmarks/src/jmh/proto/bench/v1/native_bench.proto diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java new file mode 100644 index 00000000..7defd3ea --- /dev/null +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java @@ -0,0 +1,203 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.benchmarks; + +import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchEnum; +import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.benchmarks.gen.BenchMap; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedMessage; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalar; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalarUnique; +import build.buf.protovalidate.benchmarks.gen.BenchScalar; +import build.buf.protovalidate.benchmarks.gen.MultiRule; +import build.buf.protovalidate.benchmarks.gen.StringMatching; +import build.buf.protovalidate.benchmarks.gen.TestByteMatching; +import build.buf.protovalidate.benchmarks.gen.WrapperTesting; +import com.google.protobuf.BoolValue; +import com.google.protobuf.ByteString; +import com.google.protobuf.BytesValue; +import com.google.protobuf.DoubleValue; +import com.google.protobuf.FloatValue; +import com.google.protobuf.Int32Value; +import com.google.protobuf.Int64Value; +import com.google.protobuf.StringValue; +import com.google.protobuf.UInt32Value; +import com.google.protobuf.UInt64Value; + +/** + * Hand-built deterministic fixtures for the native-rules benchmark suite. + * + *

Each factory returns a fully-populated message that satisfies all of its validation rules. + * Values are literal (no Random) so benchmarks are reproducible run-to-run. Chosen to match the + * intent of the gofakeit annotations in the Go reference protos without depending on a faker + * library. + */ +final class BenchFixtures { + private BenchFixtures() {} + + static BenchScalar benchScalar() { + return BenchScalar.newBuilder().setX(42).build(); + } + + static BenchRepeatedScalar benchRepeatedScalar() { + BenchRepeatedScalar.Builder b = BenchRepeatedScalar.newBuilder(); + for (int i = 1; i <= 5; i++) { + b.addX(i); + } + return b.build(); + } + + static BenchRepeatedMessage benchRepeatedMessage() { + BenchRepeatedMessage.Builder b = BenchRepeatedMessage.newBuilder(); + for (int i = 1; i <= 5; i++) { + b.addX(BenchScalar.newBuilder().setX(i).build()); + } + return b.build(); + } + + static BenchRepeatedScalarUnique benchRepeatedScalarUnique() { + BenchRepeatedScalarUnique.Builder b = BenchRepeatedScalarUnique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX((float) i); + } + return b.build(); + } + + static BenchRepeatedBytesUnique benchRepeatedBytesUnique() { + BenchRepeatedBytesUnique.Builder b = BenchRepeatedBytesUnique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX(ByteString.copyFromUtf8("entry-" + i)); + } + return b.build(); + } + + static BenchMap benchMap() { + BenchMap.Builder b = BenchMap.newBuilder(); + for (int i = 1; i <= 5; i++) { + b.putEntries("key-" + i, "value-" + i); + } + return b.build(); + } + + static BenchComplexSchema benchComplexSchema() { + BenchComplexSchema.Builder b = + BenchComplexSchema.newBuilder() + .setS1("hello") + .setS2("world") + .setI32(42) + .setI64(42L) + .setU32(42) + .setU64(42L) + .setSi32(42) + .setSi64(42L) + .setF32(42) + .setF64(42L) + .setSf32(42) + .setSf64(42L) + .setFl(42.0f) + .setDb(42.0) + .setBl(true) + .setBy(ByteString.copyFromUtf8("payload")) + .setNested(BenchScalar.newBuilder().setX(1).build()) + // self_ref intentionally left null; proto3 message fields default to absent + .setEnumField(BenchEnum.BENCH_ENUM_ONE) + .setOneofStr("hello"); + + for (int i = 1; i <= 3; i++) { + b.addRepStr("item-" + i); + b.addRepI32(i); + b.addRepBytes(ByteString.copyFromUtf8("bytes-" + i)); + b.addRepMsg(BenchScalar.newBuilder().setX(i).build()); + } + + for (int i = 1; i <= 3; i++) { + b.putMapStrStr("k" + i, "v" + i); + b.putMapI32I64(i, (long) i); + b.putMapU64Bool((long) i, i % 2 == 0); + b.putMapStrBytes("k" + i, ByteString.copyFromUtf8("v" + i)); + b.putMapStrMsg("k" + i, BenchScalar.newBuilder().setX(i).build()); + b.putMapI64Msg((long) i, BenchScalar.newBuilder().setX(i).build()); + } + + return b.build(); + } + + static BenchGT benchGT() { + // For gt > lt / gte > lte cases, protovalidate interprets the range as + // exclusive (value not in [lt, gt]). 50 is outside [-20, 0] for all four. + return BenchGT.newBuilder() + .setGt(50) + .setGte(50) + .setLt(50) + .setLte(50) + .setGtltin(50) + .setGtltein(50) + .setGtltex(50) + .setGtlteex(50) + .setGteltin(50) + .setGteltein(50) + .setGteltex(50) + .setGtelteex(50) + .setConst(10) + .setConstgt(10) + .setInTest(3) + .setNotInTest(4) + .build(); + } + + static TestByteMatching testByteMatching() { + return TestByteMatching.newBuilder() + .setIpAddr(ByteString.copyFrom(new byte[16])) // any 16 bytes (ip rule = 4 or 16) + .setIpv4Addr(ByteString.copyFrom(new byte[4])) + .setIpv6Addr(ByteString.copyFrom(new byte[16])) + .setUuid(ByteString.copyFrom(new byte[16])) + .build(); + } + + static StringMatching stringMatching() { + return StringMatching.newBuilder() + .setHostname("example.com") + .setHostAndPort("example.com:8080") + .setEmail("alice@example.com") + .setUuid("550e8400-e29b-41d4-a716-446655440000") + .build(); + } + + static WrapperTesting wrapperTesting() { + return WrapperTesting.newBuilder() + .setI32(Int32Value.of(11)) + .setD(DoubleValue.of(11)) + .setF(FloatValue.of(11)) + .setI64(Int64Value.of(11)) + .setU64(UInt64Value.of(11)) + .setU32(UInt32Value.of(11)) + .setB(BoolValue.of(true)) + .setS(StringValue.of("hello")) + .setBs(BytesValue.of(ByteString.copyFromUtf8("hello"))) + .build(); + } + + /** Multi-rule fixture that PASSES — many=10 satisfies const=10 and gt=5. */ + static MultiRule multiRuleNoError() { + return MultiRule.newBuilder().setMany(10).build(); + } + + /** Multi-rule fixture that FAILS both rules — many=1 violates const=10 and gt=5. */ + static MultiRule multiRuleError() { + return MultiRule.newBuilder().setMany(1).build(); + } +} \ No newline at end of file diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java new file mode 100644 index 00000000..a13ed530 --- /dev/null +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java @@ -0,0 +1,66 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.benchmarks; + +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.exceptions.ValidationException; +import com.google.protobuf.Message; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Compile-time evaluator construction benchmarks. Mirrors Go's {@code BenchmarkCompile} and {@code + * BenchmarkCompileInt32GT}. These measure how long it takes to build a validator (compile rules, + * cache evaluators) for a given message type — the cost paid once per descriptor. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@State(Scope.Benchmark) +public class EvaluatorBuildBenchmark { + + private Message benchComplexSchema; + private Message benchGT; + + @Setup + public void setup() { + benchComplexSchema = BenchComplexSchema.getDefaultInstance(); + benchGT = BenchGT.getDefaultInstance(); + } + + @Benchmark + public Validator buildBenchComplexSchema(Blackhole bh) throws ValidationException { + Validator v = ValidatorFactory.newBuilder().build(); + // Force evaluator construction by validating the default instance. + bh.consume(v.validate(benchComplexSchema)); + return v; + } + + @Benchmark + public Validator buildBenchInt32GT(Blackhole bh) throws ValidationException { + Validator v = ValidatorFactory.newBuilder().build(); + bh.consume(v.validate(benchGT)); + return v; + } +} \ No newline at end of file diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index d058b20b..fbe3e271 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -16,10 +16,22 @@ import build.buf.protovalidate.Validator; import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.benchmarks.gen.BenchMap; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedMessage; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalar; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalarUnique; +import build.buf.protovalidate.benchmarks.gen.BenchScalar; import build.buf.protovalidate.benchmarks.gen.ManyUnruledFieldsMessage; +import build.buf.protovalidate.benchmarks.gen.MultiRule; import build.buf.protovalidate.benchmarks.gen.RegexPatternMessage; import build.buf.protovalidate.benchmarks.gen.RepeatedRuleMessage; import build.buf.protovalidate.benchmarks.gen.SimpleStringMessage; +import build.buf.protovalidate.benchmarks.gen.StringMatching; +import build.buf.protovalidate.benchmarks.gen.TestByteMatching; +import build.buf.protovalidate.benchmarks.gen.WrapperTesting; import build.buf.protovalidate.exceptions.ValidationException; import com.google.protobuf.Descriptors.FieldDescriptor; import java.util.concurrent.TimeUnit; @@ -32,17 +44,45 @@ import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.infra.Blackhole; +/** + * Steady-state validation benchmarks. Exercises the hot path after the evaluator cache is warm. + * + *

The set of {@code validateBench*} methods mirrors the Go benchmark suite in + * protovalidate-go's {@code validator_bench_test.go} and provides the baseline against which the + * native-rules port measures its improvements. The original {@code validate*} methods exercise + * past PR fixes (tautology skip, AST cache, etc.) and remain as regression guards. + * + *

Phase 1 will refactor this into a {@code @Param}-driven A/B once {@code + * Config.disableNativeRules} exists; for now this is the single-mode pre-port baseline. + */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Benchmark) public class ValidationBenchmark { private Validator validator; + + // --- Existing regression-guard fixtures --- private SimpleStringMessage simple; private ManyUnruledFieldsMessage manyUnruled; private RepeatedRuleMessage repeatedRule; private RegexPatternMessage regexPattern; + // --- Native-rules port fixtures --- + private BenchScalar benchScalar; + private BenchRepeatedScalar benchRepeatedScalar; + private BenchRepeatedMessage benchRepeatedMessage; + private BenchRepeatedScalarUnique benchRepeatedScalarUnique; + private BenchRepeatedBytesUnique benchRepeatedBytesUnique; + private BenchMap benchMap; + private BenchComplexSchema benchComplexSchema; + private BenchGT benchGT; + private TestByteMatching testByteMatching; + private StringMatching stringMatching; + private WrapperTesting wrapperTesting; + private MultiRule multiRuleNoError; + private MultiRule multiRuleError; + @Setup public void setup() throws ValidationException { validator = ValidatorFactory.newBuilder().build(); @@ -71,15 +111,41 @@ public void setup() throws ValidationException { regexPattern = RegexPatternMessage.newBuilder().setName("Alice Example").build(); + benchScalar = BenchFixtures.benchScalar(); + benchRepeatedScalar = BenchFixtures.benchRepeatedScalar(); + benchRepeatedMessage = BenchFixtures.benchRepeatedMessage(); + benchRepeatedScalarUnique = BenchFixtures.benchRepeatedScalarUnique(); + benchRepeatedBytesUnique = BenchFixtures.benchRepeatedBytesUnique(); + benchMap = BenchFixtures.benchMap(); + benchComplexSchema = BenchFixtures.benchComplexSchema(); + benchGT = BenchFixtures.benchGT(); + testByteMatching = BenchFixtures.testByteMatching(); + stringMatching = BenchFixtures.stringMatching(); + wrapperTesting = BenchFixtures.wrapperTesting(); + multiRuleNoError = BenchFixtures.multiRuleNoError(); + multiRuleError = BenchFixtures.multiRuleError(); + // Warm evaluator cache for steady-state benchmarks. validator.validate(simple); validator.validate(manyUnruled); validator.validate(repeatedRule); validator.validate(regexPattern); + validator.validate(benchScalar); + validator.validate(benchRepeatedScalar); + validator.validate(benchRepeatedMessage); + validator.validate(benchRepeatedScalarUnique); + validator.validate(benchRepeatedBytesUnique); + validator.validate(benchMap); + validator.validate(benchComplexSchema); + validator.validate(benchGT); + validator.validate(testByteMatching); + validator.validate(stringMatching); + validator.validate(wrapperTesting); + validator.validate(multiRuleNoError); + validator.validate(multiRuleError); } - // Steady-state validate() benchmarks. These exercise the hot path after the - // evaluator cache is warm. + // --- Existing regression-guard benchmarks --- @Benchmark public void validateSimple(Blackhole bh) throws ValidationException { @@ -100,4 +166,71 @@ public void validateRepeatedRule(Blackhole bh) throws ValidationException { public void validateRegexPattern(Blackhole bh) throws ValidationException { bh.consume(validator.validate(regexPattern)); } -} + + // --- Native-rules port benchmarks (mirror Go BenchmarkXxx names) --- + + @Benchmark + public void validateBenchScalar(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchScalar)); + } + + @Benchmark + public void validateBenchRepeatedScalar(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedScalar)); + } + + @Benchmark + public void validateBenchRepeatedMessage(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedMessage)); + } + + @Benchmark + public void validateBenchRepeatedScalarUnique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedScalarUnique)); + } + + @Benchmark + public void validateBenchRepeatedBytesUnique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedBytesUnique)); + } + + @Benchmark + public void validateBenchMap(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchMap)); + } + + @Benchmark + public void validateBenchComplexSchema(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchComplexSchema)); + } + + @Benchmark + public void validateBenchInt32GT(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchGT)); + } + + @Benchmark + public void validateTestByteMatching(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(testByteMatching)); + } + + @Benchmark + public void validateStringMatching(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(stringMatching)); + } + + @Benchmark + public void validateWrapperTesting(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(wrapperTesting)); + } + + @Benchmark + public void validateMultiRuleNoError(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(multiRuleNoError)); + } + + @Benchmark + public void validateMultiRuleError(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(multiRuleError)); + } +} \ No newline at end of file diff --git a/benchmarks/src/jmh/proto/bench/v1/native_bench.proto b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto new file mode 100644 index 00000000..368db497 --- /dev/null +++ b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto @@ -0,0 +1,200 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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. + +// Messages ported from protovalidate-go's proto/tests/example/v1/bench.proto +// and proto/tests/example/v1/native_test.proto for the native-rules port. +// gofakeit annotations have been stripped; benchmark fixtures are hand-built +// in BenchFixtures.java. + +syntax = "proto3"; + +package bench.v1; + +import "buf/validate/validate.proto"; +import "google/protobuf/wrappers.proto"; + +option java_multiple_files = true; +option java_package = "build.buf.protovalidate.benchmarks.gen"; + +// --- Messages ported from bench.proto --- + +message BenchScalar { + int32 x = 1 [(buf.validate.field).int32.gt = 0]; +} + +message BenchRepeatedScalar { + repeated int32 x = 1 [(buf.validate.field).repeated.max_items = 10]; +} + +message BenchRepeatedMessage { + repeated BenchScalar x = 1 [(buf.validate.field).repeated.max_items = 10]; +} + +message BenchRepeatedScalarUnique { + repeated float x = 1 [(buf.validate.field).repeated.unique = true]; +} + +message BenchRepeatedBytesUnique { + repeated bytes x = 1 [(buf.validate.field).repeated.unique = true]; +} + +message BenchMap { + map entries = 1 [(buf.validate.field).map.min_pairs = 1]; +} + +message BenchComplexSchema { + string s1 = 1 [(buf.validate.field).string.min_len = 1]; + string s2 = 2 [(buf.validate.field).string.max_len = 100]; + int32 i32 = 3 [(buf.validate.field).int32.gt = 0]; + int64 i64 = 4 [(buf.validate.field).int64.lt = 1000]; + uint32 u32 = 5 [(buf.validate.field).uint32.gte = 1]; + uint64 u64 = 6 [(buf.validate.field).uint64.lte = 1000]; + sint32 si32 = 7 [(buf.validate.field).sint32.gt = 0]; + sint64 si64 = 8 [(buf.validate.field).sint64.lt = 1000]; + fixed32 f32 = 9 [(buf.validate.field).fixed32.gte = 1]; + fixed64 f64 = 10 [(buf.validate.field).fixed64.lte = 1000]; + sfixed32 sf32 = 11 [(buf.validate.field).sfixed32.gt = 0]; + sfixed64 sf64 = 12 [(buf.validate.field).sfixed64.lt = 1000]; + float fl = 13 [(buf.validate.field).float.finite = true]; + double db = 14 [(buf.validate.field).double.finite = true]; + bool bl = 15; + bytes by = 16 [(buf.validate.field).bytes.min_len = 1]; + + BenchScalar nested = 17; + + BenchComplexSchema self_ref = 18; + + repeated string rep_str = 19 [(buf.validate.field).repeated.max_items = 10]; + repeated int32 rep_i32 = 20 [(buf.validate.field).repeated.min_items = 1]; + repeated bytes rep_bytes = 21 [(buf.validate.field).repeated.unique = true]; + + repeated BenchScalar rep_msg = 22 [(buf.validate.field).repeated.max_items = 5]; + + map map_str_str = 23 [(buf.validate.field).map.min_pairs = 1]; + map map_i32_i64 = 24 [(buf.validate.field).map.max_pairs = 10]; + map map_u64_bool = 25; + map map_str_bytes = 26 [(buf.validate.field).map.keys = { + string: {min_len: 1} + }]; + + map map_str_msg = 27 [(buf.validate.field).map.values = {required: true}]; + map map_i64_msg = 28; + + BenchEnum enum_field = 29 [(buf.validate.field).enum.defined_only = true]; + + oneof choice { + string oneof_str = 30 [(buf.validate.field).string.min_len = 1]; + int32 oneof_i32 = 31 [(buf.validate.field).int32.gt = 0]; + BenchScalar oneof_msg = 32; + } +} + +enum BenchEnum { + BENCH_ENUM_UNSPECIFIED = 0; + BENCH_ENUM_ONE = 1; + BENCH_ENUM_TWO = 2; +} + +// --- Messages ported from native_test.proto --- + +message BenchGT { + int32 gt = 1 [(buf.validate.field).int32.gt = 0]; + int32 gte = 2 [(buf.validate.field).int32.gte = 0]; + int32 lt = 3 [(buf.validate.field).int32.lt = 101]; + int32 lte = 4 [(buf.validate.field).int32.lte = 101]; + int32 gtltin = 5 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gtltein = 6 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gtltex = 7 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = -20 + ]; + int32 gtlteex = 8 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lte = -20 + ]; + int32 gteltin = 9 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gteltein = 10 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101 + ]; + int32 gteltex = 11 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = -20 + ]; + int32 gtelteex = 12 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lte = -20 + ]; + int32 const = 13 [(buf.validate.field).int32.const = 10]; + int32 constgt = 14 [ + (buf.validate.field).int32.const = 10, + (buf.validate.field).int32.gte = 0 + ]; + int32 in_test = 15 [(buf.validate.field).int32 = { + in: [ + 1, + 3, + 5 + ] + }]; + int32 not_in_test = 16 [(buf.validate.field).int32 = { + not_in: [ + 1, + 3, + 5 + ] + }]; +} + +message TestByteMatching { + bytes ip_addr = 1 [(buf.validate.field).bytes.ip = true]; + bytes ipv4_addr = 2 [(buf.validate.field).bytes.ipv4 = true]; + bytes ipv6_addr = 3 [(buf.validate.field).bytes.ipv6 = true]; + bytes uuid = 4 [(buf.validate.field).bytes.uuid = true]; +} + +message StringMatching { + string hostname = 1 [(buf.validate.field).string.hostname = true]; + string host_and_port = 2 [(buf.validate.field).string.host_and_port = true]; + string email = 3 [(buf.validate.field).string.email = true]; + string uuid = 4 [(buf.validate.field).string.uuid = true]; +} + +message WrapperTesting { + google.protobuf.Int32Value i32 = 1 [(buf.validate.field).int32.gt = 10]; + google.protobuf.DoubleValue d = 2 [(buf.validate.field).double.gt = 10]; + google.protobuf.FloatValue f = 3 [(buf.validate.field).float.gt = 10]; + google.protobuf.Int64Value i64 = 4 [(buf.validate.field).int64.gt = 10]; + google.protobuf.UInt64Value u64 = 5 [(buf.validate.field).uint64.gt = 10]; + google.protobuf.UInt32Value u32 = 6 [(buf.validate.field).uint32.gt = 10]; + google.protobuf.BoolValue b = 7 [(buf.validate.field).bool.const = true]; + google.protobuf.StringValue s = 8 [(buf.validate.field).string.const = "hello"]; + google.protobuf.BytesValue bs = 9 [(buf.validate.field).bytes.len = 5]; +} + +message MultiRule { + int64 many = 1 [ + (buf.validate.field).int64.const = 10, + (buf.validate.field).int64.gt = 5 + ]; +} \ No newline at end of file From 40dc0add88d15de7b96ecc959fbd7686688e8460 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 14:42:50 -0400 Subject: [PATCH 02/31] Add native-rules dispatcher infrastructure behind opt-in flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the groundwork for replacing CEL with native Java evaluation of standard rules. Per-rule implementations land in subsequent phases; this commit only adds the surface area and the dispatch path, so behavior is unchanged when the flag is off (the default) or when no rule type has been migrated yet. Native evaluators live in build.buf.protovalidate.rules. To consume the existing evaluator pipeline they need access to a few package-private types in build.buf.protovalidate; those are widened to public and marked with a new @Internal annotation so external consumers know they remain unsupported. Forward compatibility is preserved by a clone-and-clear contract: when a future rule is added to the validate proto and the native dispatcher hasn't yet learned it, the rule stays on the residual FieldRules and CEL enforces it. Native evaluation is an optimization, never a replacement. The conformance runner reads DISABLE_NATIVE_RULES so the same suite can exercise both modes without code changes — used now to verify the dispatcher is invisible (2872/2872 in both modes), and again later for parity testing. --- .../benchmarks/BenchFixtures.java | 2 +- .../benchmarks/EvaluatorBuildBenchmark.java | 13 ++- .../benchmarks/ValidationBenchmark.java | 23 +++-- .../buf/protovalidate/conformance/Main.java | 15 ++- .../java/build/buf/protovalidate/Config.java | 38 +++++++- .../build/buf/protovalidate/Evaluator.java | 6 +- .../buf/protovalidate/EvaluatorBuilder.java | 24 ++++- .../buf/protovalidate/FieldPathUtils.java | 16 +++- .../build/buf/protovalidate/Internal.java | 30 ++++++ .../buf/protovalidate/RuleViolation.java | 39 ++++---- .../java/build/buf/protovalidate/Value.java | 6 +- .../buf/protovalidate/ValueEvaluator.java | 13 ++- .../protovalidate/rules/NativeViolations.java | 71 ++++++++++++++ .../buf/protovalidate/rules/RuleBase.java | 84 +++++++++++++++++ .../buf/protovalidate/rules/RuleSite.java | 92 ++++++++++++++++++ .../build/buf/protovalidate/rules/Rules.java | 93 +++++++++++++++++++ 16 files changed, 516 insertions(+), 49 deletions(-) create mode 100644 src/main/java/build/buf/protovalidate/Internal.java create mode 100644 src/main/java/build/buf/protovalidate/rules/NativeViolations.java create mode 100644 src/main/java/build/buf/protovalidate/rules/RuleBase.java create mode 100644 src/main/java/build/buf/protovalidate/rules/RuleSite.java create mode 100644 src/main/java/build/buf/protovalidate/rules/Rules.java diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java index 7defd3ea..7f5b57ce 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java @@ -200,4 +200,4 @@ static MultiRule multiRuleNoError() { static MultiRule multiRuleError() { return MultiRule.newBuilder().setMany(1).build(); } -} \ No newline at end of file +} diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java index a13ed530..01ac6df0 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java @@ -14,6 +14,7 @@ package build.buf.protovalidate.benchmarks; +import build.buf.protovalidate.Config; import build.buf.protovalidate.Validator; import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; @@ -25,6 +26,7 @@ import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; import org.openjdk.jmh.annotations.Scope; import org.openjdk.jmh.annotations.Setup; import org.openjdk.jmh.annotations.State; @@ -40,18 +42,23 @@ @State(Scope.Benchmark) public class EvaluatorBuildBenchmark { + @Param({"true", "false"}) + public boolean disableNativeRules; + + private Config config; private Message benchComplexSchema; private Message benchGT; @Setup public void setup() { + config = Config.newBuilder().setDisableNativeRules(disableNativeRules).build(); benchComplexSchema = BenchComplexSchema.getDefaultInstance(); benchGT = BenchGT.getDefaultInstance(); } @Benchmark public Validator buildBenchComplexSchema(Blackhole bh) throws ValidationException { - Validator v = ValidatorFactory.newBuilder().build(); + Validator v = ValidatorFactory.newBuilder().withConfig(config).build(); // Force evaluator construction by validating the default instance. bh.consume(v.validate(benchComplexSchema)); return v; @@ -59,8 +66,8 @@ public Validator buildBenchComplexSchema(Blackhole bh) throws ValidationExceptio @Benchmark public Validator buildBenchInt32GT(Blackhole bh) throws ValidationException { - Validator v = ValidatorFactory.newBuilder().build(); + Validator v = ValidatorFactory.newBuilder().withConfig(config).build(); bh.consume(v.validate(benchGT)); return v; } -} \ No newline at end of file +} diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index fbe3e271..3d06d97b 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -14,6 +14,7 @@ package build.buf.protovalidate.benchmarks; +import build.buf.protovalidate.Config; import build.buf.protovalidate.Validator; import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; @@ -39,6 +40,7 @@ import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; import org.openjdk.jmh.annotations.Scope; import org.openjdk.jmh.annotations.Setup; import org.openjdk.jmh.annotations.State; @@ -47,19 +49,23 @@ /** * Steady-state validation benchmarks. Exercises the hot path after the evaluator cache is warm. * - *

The set of {@code validateBench*} methods mirrors the Go benchmark suite in - * protovalidate-go's {@code validator_bench_test.go} and provides the baseline against which the - * native-rules port measures its improvements. The original {@code validate*} methods exercise - * past PR fixes (tautology skip, AST cache, etc.) and remain as regression guards. + *

The set of {@code validateBench*} methods mirrors the Go benchmark suite in protovalidate-go's + * {@code validator_bench_test.go} and provides the baseline against which the native-rules port + * measures its improvements. The original {@code validate*} methods exercise past PR fixes + * (tautology skip, AST cache, etc.) and remain as regression guards. * - *

Phase 1 will refactor this into a {@code @Param}-driven A/B once {@code - * Config.disableNativeRules} exists; for now this is the single-mode pre-port baseline. + *

The {@code disableNativeRules} parameter A/Bs the native-rules flag: {@code "true"} matches + * the Phase 0 CEL-only baseline; {@code "false"} measures native evaluation. Each subsequent phase + * reports the gap between the two modes for its covered benchmarks. */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @State(Scope.Benchmark) public class ValidationBenchmark { + @Param({"true", "false"}) + public boolean disableNativeRules; + private Validator validator; // --- Existing regression-guard fixtures --- @@ -85,7 +91,8 @@ public class ValidationBenchmark { @Setup public void setup() throws ValidationException { - validator = ValidatorFactory.newBuilder().build(); + Config config = Config.newBuilder().setDisableNativeRules(disableNativeRules).build(); + validator = ValidatorFactory.newBuilder().withConfig(config).build(); simple = SimpleStringMessage.newBuilder().setEmail("alice@example.com").build(); @@ -233,4 +240,4 @@ public void validateMultiRuleNoError(Blackhole bh) throws ValidationException { public void validateMultiRuleError(Blackhole bh) throws ValidationException { bh.consume(validator.validate(multiRuleError)); } -} \ No newline at end of file +} diff --git a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java index bf2c5d63..0d432a2a 100644 --- a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java +++ b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java @@ -61,11 +61,16 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) { TypeRegistry typeRegistry = FileDescriptorUtil.createTypeRegistry(fileDescriptorMap.values()); ExtensionRegistry extensionRegistry = FileDescriptorUtil.createExtensionRegistry(fileDescriptorMap.values()); - Config cfg = - Config.newBuilder() - .setTypeRegistry(typeRegistry) - .setExtensionRegistry(extensionRegistry) - .build(); + // DISABLE_NATIVE_RULES env var lets the conformance runner exercise both rule-evaluation + // modes without code changes. Defaults to whatever Config's default is so a plain + // `gradlew :conformance:test` matches user-facing behavior. + String envFlag = System.getenv("DISABLE_NATIVE_RULES"); + Config.Builder cfgBuilder = + Config.newBuilder().setTypeRegistry(typeRegistry).setExtensionRegistry(extensionRegistry); + if (envFlag != null) { + cfgBuilder.setDisableNativeRules(Boolean.parseBoolean(envFlag)); + } + Config cfg = cfgBuilder.build(); Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build(); TestConformanceResponse.Builder responseBuilder = TestConformanceResponse.newBuilder(); diff --git a/src/main/java/build/buf/protovalidate/Config.java b/src/main/java/build/buf/protovalidate/Config.java index 5317c503..b4dab3d3 100644 --- a/src/main/java/build/buf/protovalidate/Config.java +++ b/src/main/java/build/buf/protovalidate/Config.java @@ -27,16 +27,19 @@ public final class Config { private final TypeRegistry typeRegistry; private final ExtensionRegistry extensionRegistry; private final boolean allowUnknownFields; + private final boolean disableNativeRules; private Config( boolean failFast, TypeRegistry typeRegistry, ExtensionRegistry extensionRegistry, - boolean allowUnknownFields) { + boolean allowUnknownFields, + boolean disableNativeRules) { this.failFast = failFast; this.typeRegistry = typeRegistry; this.extensionRegistry = extensionRegistry; this.allowUnknownFields = allowUnknownFields; + this.disableNativeRules = disableNativeRules; } /** @@ -84,12 +87,27 @@ public boolean isAllowingUnknownFields() { return allowUnknownFields; } + /** + * Checks whether native (non-CEL) rule evaluators are disabled. + * + *

When false, standard rules with a native Java implementation bypass CEL evaluation. When + * true, all rules go through CEL. Defaults to true while the native-rules port is in flight; + * applications opt in by calling {@link Builder#setDisableNativeRules(boolean) + * setDisableNativeRules(false)}. + * + * @return true if native rules are disabled (CEL handles everything). + */ + public boolean isNativeRulesDisabled() { + return disableNativeRules; + } + /** Builder for configuration. Provides a forward compatible API for users. */ public static final class Builder { private boolean failFast; private TypeRegistry typeRegistry = DEFAULT_TYPE_REGISTRY; private ExtensionRegistry extensionRegistry = DEFAULT_EXTENSION_REGISTRY; private boolean allowUnknownFields; + private boolean disableNativeRules = true; private Builder() {} @@ -157,13 +175,29 @@ public Builder setAllowUnknownFields(boolean allowUnknownFields) { return this; } + /** + * Set whether native (non-CEL) rule evaluators are disabled. Defaults to true while the + * native-rules port is in flight; pass false to opt in to native evaluation of standard rules. + * Forward-compatible: any rule not yet implemented natively continues to be enforced via CEL + * regardless of this setting. + * + * @param disableNativeRules true to disable native rules and route everything through CEL; + * false to use native evaluators where available. + * @return this builder + */ + public Builder setDisableNativeRules(boolean disableNativeRules) { + this.disableNativeRules = disableNativeRules; + return this; + } + /** * Build the corresponding {@link Config}. * * @return the configuration. */ public Config build() { - return new Config(failFast, typeRegistry, extensionRegistry, allowUnknownFields); + return new Config( + failFast, typeRegistry, extensionRegistry, allowUnknownFields, disableNativeRules); } } } diff --git a/src/main/java/build/buf/protovalidate/Evaluator.java b/src/main/java/build/buf/protovalidate/Evaluator.java index 695623d4..4770d483 100644 --- a/src/main/java/build/buf/protovalidate/Evaluator.java +++ b/src/main/java/build/buf/protovalidate/Evaluator.java @@ -20,8 +20,12 @@ /** * {@link Evaluator} defines a validation evaluator. evaluator implementations may elide type * checking of the passed in value, as the types have been guaranteed during the build phase. + * + *

Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can implement + * it; not part of the supported public API. */ -interface Evaluator { +@Internal +public interface Evaluator { /** * Tautology returns true if the evaluator always succeeds. * diff --git a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java index 05f8c1df..aaf33c86 100644 --- a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java +++ b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java @@ -15,6 +15,7 @@ package build.buf.protovalidate; import build.buf.protovalidate.exceptions.CompilationException; +import build.buf.protovalidate.rules.Rules; import build.buf.validate.FieldPath; import build.buf.validate.FieldPathElement; import build.buf.validate.FieldRules; @@ -60,6 +61,7 @@ final class EvaluatorBuilder { private final Cel cel; private final boolean disableLazy; + private final boolean disableNativeRules; private final RuleCache rules; /** @@ -71,6 +73,7 @@ final class EvaluatorBuilder { EvaluatorBuilder(Cel cel, Config config) { this.cel = cel; this.disableLazy = false; + this.disableNativeRules = config.isNativeRulesDisabled(); this.rules = new RuleCache(cel, config); } @@ -85,6 +88,7 @@ final class EvaluatorBuilder { Objects.requireNonNull(descriptors, "descriptors must not be null"); this.cel = cel; this.disableLazy = disableLazy; + this.disableNativeRules = config.isNativeRulesDisabled(); this.rules = new RuleCache(cel, config); for (Descriptor descriptor : descriptors) { @@ -126,7 +130,7 @@ private Evaluator build(Descriptor desc) throws CompilationException { } // Rebuild cache with this descriptor (and any of its dependencies). Map updatedCache = - new DescriptorCacheBuilder(cel, rules, evaluatorCache).build(desc); + new DescriptorCacheBuilder(cel, rules, disableNativeRules, evaluatorCache).build(desc); evaluatorCache = updatedCache; eval = updatedCache.get(desc); if (eval == null) { @@ -141,12 +145,17 @@ private static class DescriptorCacheBuilder { private final RuleResolver resolver = new RuleResolver(); private final Cel cel; private final RuleCache ruleCache; + private final boolean disableNativeRules; private final HashMap cache; private DescriptorCacheBuilder( - Cel cel, RuleCache ruleCache, Map previousCache) { + Cel cel, + RuleCache ruleCache, + boolean disableNativeRules, + Map previousCache) { this.cel = Objects.requireNonNull(cel, "cel"); this.ruleCache = Objects.requireNonNull(ruleCache, "ruleCache"); + this.disableNativeRules = disableNativeRules; this.cache = new HashMap<>(previousCache); } @@ -463,6 +472,17 @@ private void processStandardRules( } } + // Try native rule evaluators (Phase 1: dispatcher always returns null). Any rule covered + // natively is cleared on the residual builder so CEL only compiles what's left. + if (!disableNativeRules) { + FieldRules.Builder rulesBuilder = fieldRules.toBuilder(); + Evaluator nativeEval = Rules.tryBuild(fieldDescriptor, rulesBuilder, valueEvaluatorEval); + if (nativeEval != null) { + valueEvaluatorEval.append(nativeEval); + fieldRules = rulesBuilder.build(); + } + } + List compile = ruleCache.compile(fieldDescriptor, fieldRules, valueEvaluatorEval.hasNestedRule()); if (compile.isEmpty()) { diff --git a/src/main/java/build/buf/protovalidate/FieldPathUtils.java b/src/main/java/build/buf/protovalidate/FieldPathUtils.java index 4d34cfd3..77d7c730 100644 --- a/src/main/java/build/buf/protovalidate/FieldPathUtils.java +++ b/src/main/java/build/buf/protovalidate/FieldPathUtils.java @@ -20,8 +20,14 @@ import java.util.List; import org.jspecify.annotations.Nullable; -/** Utility class for manipulating error paths in violations. */ -final class FieldPathUtils { +/** + * Utility class for manipulating error paths in violations. + * + *

Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can build and + * prepend field path elements; not part of the supported public API. + */ +@Internal +public final class FieldPathUtils { private FieldPathUtils() {} /** @@ -30,7 +36,7 @@ private FieldPathUtils() {} * @param fieldPath A field path to convert to a string. * @return The string representation of the provided field path. */ - static String fieldPathString(FieldPath fieldPath) { + public static String fieldPathString(FieldPath fieldPath) { StringBuilder builder = new StringBuilder(); for (FieldPathElement element : fieldPath.getElementsList()) { if (builder.length() > 0) { @@ -78,7 +84,7 @@ static String fieldPathString(FieldPath fieldPath) { * @param fieldDescriptor The field descriptor to generate a field path element for. * @return The field path element that corresponds to the provided field descriptor. */ - static FieldPathElement fieldPathElement(Descriptors.FieldDescriptor fieldDescriptor) { + public static FieldPathElement fieldPathElement(Descriptors.FieldDescriptor fieldDescriptor) { String name; if (fieldDescriptor.isExtension()) { name = "[" + fieldDescriptor.getFullName() + "]"; @@ -100,7 +106,7 @@ static FieldPathElement fieldPathElement(Descriptors.FieldDescriptor fieldDescri * @param rulePathElements Rule path elements to prepend. * @return For convenience, the list of violations passed into the violations parameter. */ - static List updatePaths( + public static List updatePaths( List violations, @Nullable FieldPathElement fieldPathElement, List rulePathElements) { diff --git a/src/main/java/build/buf/protovalidate/Internal.java b/src/main/java/build/buf/protovalidate/Internal.java new file mode 100644 index 00000000..7109d586 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/Internal.java @@ -0,0 +1,30 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks types and members that are public for the protovalidate-java codebase's own use (e.g. + * cross-package references between {@code build.buf.protovalidate} and its sub-packages) but are + * not part of the supported public API. Code outside this library must not depend on these — they + * may change or disappear at any time without notice. + */ +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +public @interface Internal {} diff --git a/src/main/java/build/buf/protovalidate/RuleViolation.java b/src/main/java/build/buf/protovalidate/RuleViolation.java index 454d2f78..66d3af68 100644 --- a/src/main/java/build/buf/protovalidate/RuleViolation.java +++ b/src/main/java/build/buf/protovalidate/RuleViolation.java @@ -27,13 +27,17 @@ /** * {@link RuleViolation} contains all the collected information about an individual rule violation. + * + *

Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can construct + * violation builders; not part of the supported public API. */ -final class RuleViolation implements Violation { +@Internal +public final class RuleViolation implements Violation { /** Static value to return when there are no violations. */ - static final List NO_VIOLATIONS = Collections.emptyList(); + public static final List NO_VIOLATIONS = Collections.emptyList(); /** {@link FieldValue} represents a Protobuf field value inside a Protobuf message. */ - static class FieldValue implements Violation.FieldValue { + public static class FieldValue implements Violation.FieldValue { private final @Nullable Object value; private final Descriptors.FieldDescriptor descriptor; @@ -43,7 +47,7 @@ static class FieldValue implements Violation.FieldValue { * @param value Bare Protobuf field value of field. * @param descriptor Field descriptor pertaining to this field. */ - FieldValue(@Nullable Object value, Descriptors.FieldDescriptor descriptor) { + public FieldValue(@Nullable Object value, Descriptors.FieldDescriptor descriptor) { this.value = value; this.descriptor = descriptor; } @@ -54,7 +58,7 @@ static class FieldValue implements Violation.FieldValue { * * @param value A {@link Value} to create this {@link FieldValue} from. */ - FieldValue(Value value) { + public FieldValue(Value value) { this.value = value.value(Object.class); this.descriptor = Objects.requireNonNull(value.fieldDescriptor()); } @@ -75,7 +79,7 @@ public Descriptors.FieldDescriptor getDescriptor() { private final @Nullable FieldValue ruleValue; /** Builds a Violation instance. */ - static class Builder { + public static class Builder { private @Nullable String ruleId; private @Nullable String message; private boolean forKey = false; @@ -90,7 +94,7 @@ static class Builder { * @param ruleId Rule ID value to use. * @return The builder. */ - Builder setRuleId(String ruleId) { + public Builder setRuleId(String ruleId) { this.ruleId = ruleId; return this; } @@ -101,7 +105,7 @@ Builder setRuleId(String ruleId) { * @param message Message value to use. * @return The builder. */ - Builder setMessage(String message) { + public Builder setMessage(String message) { this.message = message; return this; } @@ -112,7 +116,7 @@ Builder setMessage(String message) { * @param forKey If true, signals that the resulting violation is for a map key. * @return The builder. */ - Builder setForKey(boolean forKey) { + public Builder setForKey(boolean forKey) { this.forKey = forKey; return this; } @@ -123,7 +127,8 @@ Builder setForKey(boolean forKey) { * @param fieldPathElements Field path elements to add. * @return The builder. */ - Builder addAllFieldPathElements(Collection fieldPathElements) { + public Builder addAllFieldPathElements( + Collection fieldPathElements) { this.fieldPath.addAll(fieldPathElements); return this; } @@ -134,7 +139,7 @@ Builder addAllFieldPathElements(Collection fieldPath * @param fieldPathElement A field path element to add to the beginning of the field path. * @return The builder. */ - Builder addFirstFieldPathElement(@Nullable FieldPathElement fieldPathElement) { + public Builder addFirstFieldPathElement(@Nullable FieldPathElement fieldPathElement) { if (fieldPathElement != null) { fieldPath.addFirst(fieldPathElement); } @@ -147,7 +152,7 @@ Builder addFirstFieldPathElement(@Nullable FieldPathElement fieldPathElement) { * @param rulePathElements Field path elements to add. * @return The builder. */ - Builder addAllRulePathElements(Collection rulePathElements) { + public Builder addAllRulePathElements(Collection rulePathElements) { rulePath.addAll(rulePathElements); return this; } @@ -158,7 +163,7 @@ Builder addAllRulePathElements(Collection rulePathEl * @param rulePathElements A field path element to add to the beginning of the rule path. * @return The builder. */ - Builder addFirstRulePathElement(FieldPathElement rulePathElements) { + public Builder addFirstRulePathElement(FieldPathElement rulePathElements) { rulePath.addFirst(rulePathElements); return this; } @@ -169,7 +174,7 @@ Builder addFirstRulePathElement(FieldPathElement rulePathElements) { * @param fieldValue The field value corresponding to this violation. * @return The builder. */ - Builder setFieldValue(@Nullable FieldValue fieldValue) { + public Builder setFieldValue(@Nullable FieldValue fieldValue) { this.fieldValue = fieldValue; return this; } @@ -180,7 +185,7 @@ Builder setFieldValue(@Nullable FieldValue fieldValue) { * @param ruleValue The rule value corresponding to this violation. * @return The builder. */ - Builder setRuleValue(@Nullable FieldValue ruleValue) { + public Builder setRuleValue(@Nullable FieldValue ruleValue) { this.ruleValue = ruleValue; return this; } @@ -190,7 +195,7 @@ Builder setRuleValue(@Nullable FieldValue ruleValue) { * * @return A Violation instance. */ - RuleViolation build() { + public RuleViolation build() { build.buf.validate.Violation.Builder protoBuilder = build.buf.validate.Violation.newBuilder(); if (ruleId != null) { protoBuilder.setRuleId(ruleId); @@ -218,7 +223,7 @@ private Builder() {} * * @return A new, empty {@link Builder}. */ - static Builder newBuilder() { + public static Builder newBuilder() { return new Builder(); } diff --git a/src/main/java/build/buf/protovalidate/Value.java b/src/main/java/build/buf/protovalidate/Value.java index 6adac431..d762e824 100644 --- a/src/main/java/build/buf/protovalidate/Value.java +++ b/src/main/java/build/buf/protovalidate/Value.java @@ -23,8 +23,12 @@ /** * {@link Value} is a wrapper around a protobuf value that provides helper methods for accessing the * value. + * + *

Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can consume it; + * not part of the supported public API. */ -interface Value { +@Internal +public interface Value { /** * Get the field descriptor that corresponds to the underlying Value, if it is a message field. * diff --git a/src/main/java/build/buf/protovalidate/ValueEvaluator.java b/src/main/java/build/buf/protovalidate/ValueEvaluator.java index 433da1c4..2791372f 100644 --- a/src/main/java/build/buf/protovalidate/ValueEvaluator.java +++ b/src/main/java/build/buf/protovalidate/ValueEvaluator.java @@ -25,8 +25,13 @@ /** * {@link ValueEvaluator} performs validation on any concrete value contained within a singular * field, repeated elements, or the keys/values of a map. + * + *

Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can be + * constructed with descriptor/nested-rule context from a {@link ValueEvaluator}; not part of the + * supported public API. */ -final class ValueEvaluator implements Evaluator { +@Internal +public final class ValueEvaluator implements Evaluator { /** The {@link Descriptors.FieldDescriptor} targeted by this evaluator */ private final Descriptors.@Nullable FieldDescriptor descriptor; @@ -51,15 +56,15 @@ final class ValueEvaluator implements Evaluator { this.nestedRule = nestedRule; } - Descriptors.@Nullable FieldDescriptor getDescriptor() { + public Descriptors.@Nullable FieldDescriptor getDescriptor() { return descriptor; } - @Nullable FieldPath getNestedRule() { + public @Nullable FieldPath getNestedRule() { return nestedRule; } - boolean hasNestedRule() { + public boolean hasNestedRule() { return this.nestedRule != null; } diff --git a/src/main/java/build/buf/protovalidate/rules/NativeViolations.java b/src/main/java/build/buf/protovalidate/rules/NativeViolations.java new file mode 100644 index 00000000..c094bfaf --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/NativeViolations.java @@ -0,0 +1,71 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import org.jspecify.annotations.Nullable; + +/** + * Builds {@link RuleViolation.Builder} instances for native rule evaluators. + * + *

The resulting builder carries only rule-relative state: rule id, message, the rule path suffix + * from {@link RuleSite}, and optional field/rule values. Field path and any nested-rule prefix are + * prepended later by {@code FieldPathUtils.updatePaths} when the violations leave the native + * evaluator's {@code evaluate} — the same pattern {@code CelPrograms} uses. + */ +final class NativeViolations { + private NativeViolations() {} + + /** + * Builds a violation for a rule failure. If {@link RuleSite#getRuleId()} or {@link + * RuleSite#getMessage()} return non-null they take precedence over the supplied {@code ruleId} + * and {@code message} arguments; this lets a {@link RuleSite} pre-bake constant text and skip the + * per-call argument when there's nothing dynamic to report. + * + * @param site the rule site (rule path suffix + leaf descriptor + optional pre-baked id/message) + * @param ruleId rule id to use when the site doesn't have one pre-baked + * @param message violation message to use when the site doesn't have one pre-baked + * @param fieldValue the failing field value (its descriptor is used to populate {@code + * field_value} on the violation); pass null to omit + * @param ruleValue the rule's bound value (e.g. the {@code 5} in {@code min_len = 5}), bound to + * the site's leaf descriptor; pass null to omit + */ + static RuleViolation.Builder newViolation( + RuleSite site, + @Nullable String ruleId, + @Nullable String message, + @Nullable Value fieldValue, + @Nullable Object ruleValue) { + String effectiveRuleId = (site.getRuleId() != null) ? site.getRuleId() : ruleId; + String effectiveMessage = (site.getMessage() != null) ? site.getMessage() : message; + + RuleViolation.Builder builder = RuleViolation.newBuilder(); + if (effectiveRuleId != null) { + builder.setRuleId(effectiveRuleId); + } + if (effectiveMessage != null) { + builder.setMessage(effectiveMessage); + } + builder.addAllRulePathElements(site.getPathElements()); + if (fieldValue != null && fieldValue.fieldDescriptor() != null) { + builder.setFieldValue(new RuleViolation.FieldValue(fieldValue)); + } + if (ruleValue != null) { + builder.setRuleValue(new RuleViolation.FieldValue(ruleValue, site.getLeafDescriptor())); + } + return builder; + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/RuleBase.java b/src/main/java/build/buf/protovalidate/rules/RuleBase.java new file mode 100644 index 00000000..b128a89f --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/RuleBase.java @@ -0,0 +1,84 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.ValueEvaluator; +import build.buf.validate.FieldPath; +import build.buf.validate.FieldPathElement; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Common context shared across native rule evaluators: the field's descriptor, its single + * containing-message field path element, and any nested-rule prefix that must be prepended to rule + * paths in violations. + * + *

Mirrors the {@code base} struct in protovalidate-go's {@code base.go}, adapted to Java's + * existing pattern of letting violations bubble up the call stack with prepended path elements (see + * {@link FieldPathUtils#updatePaths}). + */ +final class RuleBase { + private static final List EMPTY_PREFIX = Collections.emptyList(); + + private final @Nullable FieldDescriptor descriptor; + private final @Nullable FieldPathElement fieldPathElement; + private final @Nullable FieldPath rulePrefix; + + private RuleBase( + @Nullable FieldDescriptor descriptor, + @Nullable FieldPathElement fieldPathElement, + @Nullable FieldPath rulePrefix) { + this.descriptor = descriptor; + this.fieldPathElement = fieldPathElement; + this.rulePrefix = rulePrefix; + } + + /** + * Builds a {@link RuleBase} from the given {@link ValueEvaluator}, computing the field path + * element from its descriptor and capturing its nested-rule prefix. + */ + static RuleBase of(ValueEvaluator valueEvaluator) { + FieldDescriptor desc = valueEvaluator.getDescriptor(); + FieldPathElement fpe = (desc != null) ? FieldPathUtils.fieldPathElement(desc) : null; + return new RuleBase(desc, fpe, valueEvaluator.getNestedRule()); + } + + /** The descriptor of the field being validated, or null when validating a non-field value. */ + @Nullable FieldDescriptor getDescriptor() { + return descriptor; + } + + /** + * The {@link FieldPathElement} for prepending to violation field paths, or null when there is no + * field context (e.g. the value being validated is not a message field). + */ + @Nullable FieldPathElement getFieldPathElement() { + return fieldPathElement; + } + + /** + * The nested-rule path elements (e.g. {@code repeated.items}, {@code map.keys}) to prepend to + * violation rule paths. Empty when there is no nested-rule context. + */ + List getRulePrefixElements() { + if (rulePrefix == null) { + return EMPTY_PREFIX; + } + return rulePrefix.getElementsList(); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/RuleSite.java b/src/main/java/build/buf/protovalidate/rules/RuleSite.java new file mode 100644 index 00000000..405c4624 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/RuleSite.java @@ -0,0 +1,92 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.FieldPathUtils; +import build.buf.validate.FieldPathElement; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * A pre-built bundle for a single rule site: the two-element rule-path suffix ({@code + * [FieldRules., Rules.]}), the leaf rule descriptor, and an optional constant + * rule id and message. + * + *

Each native rule type instantiates one {@link RuleSite} per supported rule at class init time + * so violation construction at validation time only allocates the violation builder itself, not the + * path-element protos. Mirrors the {@code ruleSite} struct in protovalidate-go's {@code base.go}. + */ +final class RuleSite { + private final List pathElements; + private final FieldDescriptor leafDescriptor; + private final @Nullable String ruleId; + private final @Nullable String message; + + private RuleSite( + List pathElements, + FieldDescriptor leafDescriptor, + @Nullable String ruleId, + @Nullable String message) { + this.pathElements = pathElements; + this.leafDescriptor = leafDescriptor; + this.ruleId = ruleId; + this.message = message; + } + + /** + * Builds a {@link RuleSite} from a rule-type field descriptor (e.g. {@code FieldRules.string}) + * and a leaf rule field descriptor (e.g. {@code StringRules.min_len}). + * + * @param ruleTypeDescriptor descriptor of the {@code FieldRules} oneof case (e.g. the {@code + * string} field on {@code FieldRules}). + * @param leafDescriptor descriptor of the specific rule (e.g. {@code min_len}). + * @param ruleId optional constant rule id for this site (e.g. {@code "string.min_len"}); may be + * null when the rule id is computed per violation (e.g. well-known formats with empty/error + * variants). + * @param message optional constant violation message; may be null when the message is built per + * violation from the failing value. + */ + static RuleSite of( + FieldDescriptor ruleTypeDescriptor, + FieldDescriptor leafDescriptor, + @Nullable String ruleId, + @Nullable String message) { + List elements = + Collections.unmodifiableList( + Arrays.asList( + FieldPathUtils.fieldPathElement(ruleTypeDescriptor), + FieldPathUtils.fieldPathElement(leafDescriptor))); + return new RuleSite(elements, leafDescriptor, ruleId, message); + } + + List getPathElements() { + return pathElements; + } + + FieldDescriptor getLeafDescriptor() { + return leafDescriptor; + } + + @Nullable String getRuleId() { + return ruleId; + } + + @Nullable String getMessage() { + return message; + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java new file mode 100644 index 00000000..30c85ebf --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -0,0 +1,93 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.Internal; +import build.buf.protovalidate.ValueEvaluator; +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.jspecify.annotations.Nullable; + +/** + * Entry point for native rule evaluators. {@code EvaluatorBuilder} calls {@link #tryBuild} once per + * field; if a native evaluator covers some rules, those rules are cleared on the supplied {@code + * FieldRules.Builder} and the residual is then handed to CEL compilation. Returns null when no + * native evaluator applies — CEL handles the field unchanged. + * + *

The clone-and-clear contract ensures forward compatibility: when protovalidate adds a new rule + * that this codebase hasn't yet implemented natively, the rule remains on the residual {@code + * FieldRules} and CEL enforces it. Native rules are an optimization, not a replacement. + */ +@Internal +public final class Rules { + private Rules() {} + + /** + * Attempts to build a native evaluator for the standard rules on {@code fieldDescriptor}. + * + *

Any rule covered natively is cleared on {@code rulesBuilder} so that {@code RuleCache} + * compiles CEL programs only for rules left untouched. The caller is expected to pass a builder + * it owns (typically obtained via {@code fieldRules.toBuilder()} on a clone) and to call {@code + * build()} on the residual before handing it to {@code RuleCache.compile}. + * + * @param fieldDescriptor the field being evaluated + * @param rulesBuilder a mutable builder of the field's {@link FieldRules}; covered rules are + * cleared in place + * @param valueEvaluator the value evaluator the native evaluator will be appended to + * @return a native {@link Evaluator}, or null if no native evaluator applies (CEL handles + * everything) + */ + public static @Nullable Evaluator tryBuild( + FieldDescriptor fieldDescriptor, + FieldRules.Builder rulesBuilder, + ValueEvaluator valueEvaluator) { + boolean hasNestedRule = valueEvaluator.hasNestedRule(); + if (fieldDescriptor.isMapField() && !hasNestedRule) { + return tryBuildMapRules(rulesBuilder, valueEvaluator); + } + if (fieldDescriptor.isRepeated() && !hasNestedRule) { + return tryBuildRepeatedRules(rulesBuilder, valueEvaluator); + } + if (!fieldDescriptor.isMapField() && !fieldDescriptor.isRepeated()) { + return tryBuildScalarRules(fieldDescriptor, rulesBuilder, valueEvaluator); + } + return null; + } + + // Phase 1: dispatcher skeleton. Per-kind builders are introduced in subsequent phases. + // Phase 2 wires bool, Phase 3 numeric, Phase 4 enum, Phase 5 bytes, Phase 6 string, + // Phase 7 repeated/map. + + @SuppressWarnings("unused") + private static @Nullable Evaluator tryBuildScalarRules( + FieldDescriptor fieldDescriptor, + FieldRules.Builder rulesBuilder, + ValueEvaluator valueEvaluator) { + return null; + } + + @SuppressWarnings("unused") + private static @Nullable Evaluator tryBuildRepeatedRules( + FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { + return null; + } + + @SuppressWarnings("unused") + private static @Nullable Evaluator tryBuildMapRules( + FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { + return null; + } +} From 00a4c215244446313c07ea0fed8bc92740b63089 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 15:17:46 -0400 Subject: [PATCH 03/31] Add native evaluator for bool.const MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First rule type to land natively. Confirms the dispatcher contract end to end — the violation shape, rule path, field path, and rule_value match what the CEL path produces for the same input. Wrapper-typed scalar fields bypass the dispatcher; CEL's transparent unwrapping handles them correctly today and adding native wrapper support uniformly across all scalar rule types is more naturally a follow-up once more rule types have shipped. The test asserts on Violation.getRuleValue() explicitly, since that field isn't part of the Violation proto and is therefore invisible to the conformance suite — a parity-loss vector that needs Java-side coverage to catch. --- .../rules/BoolRulesEvaluator.java | 88 ++++++++++++++ .../build/buf/protovalidate/rules/Rules.java | 22 +++- .../rules/BoolRulesEvaluatorTest.java | 114 ++++++++++++++++++ .../proto/validationtest/validationtest.proto | 4 + 4 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java create mode 100644 src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java diff --git a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java new file mode 100644 index 00000000..543a0f6d --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java @@ -0,0 +1,88 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.validate.BoolRules; +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for {@code bool} rules. Currently covers {@code bool.const}; the only standard + * rule defined for bool fields. + */ +final class BoolRulesEvaluator implements Evaluator { + private static final FieldDescriptor BOOL_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.BOOL_FIELD_NUMBER); + private static final FieldDescriptor CONST_DESC = + BoolRules.getDescriptor().findFieldByNumber(BoolRules.CONST_FIELD_NUMBER); + private static final RuleSite CONST_SITE = + RuleSite.of(BOOL_RULES_DESC, CONST_DESC, "bool.const", null); + + private final RuleBase base; + private final boolean expected; + + private BoolRulesEvaluator(RuleBase base, boolean expected) { + this.base = base; + this.expected = expected; + } + + /** + * Attempts to build a {@link BoolRulesEvaluator} for the bool sub-rules on the given {@code + * FieldRules.Builder}. Returns null if the rules aren't natively handleable (no bool oneof case + * set, no covered rule set, or unknown fields present); on success, clears the covered rule on + * the builder so CEL won't recompile it. + */ + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasBool()) { + return null; + } + BoolRules boolRules = rulesBuilder.getBool(); + if (!boolRules.getUnknownFields().asMap().isEmpty()) { + return null; + } + if (!boolRules.hasConst()) { + return null; + } + boolean expected = boolRules.getConst(); + rulesBuilder.setBool(boolRules.toBuilder().clearConst().build()); + return new BoolRulesEvaluator(base, expected); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + boolean actual = val.value(Boolean.class); + if (actual == expected) { + return RuleViolation.NO_VIOLATIONS; + } + List violations = + Collections.singletonList( + NativeViolations.newViolation( + CONST_SITE, null, "must equal " + expected, val, expected)); + return FieldPathUtils.updatePaths( + violations, base.getFieldPathElement(), base.getRulePrefixElements()); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index 30c85ebf..a8bccf0a 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -62,21 +62,33 @@ private Rules() {} return tryBuildRepeatedRules(rulesBuilder, valueEvaluator); } if (!fieldDescriptor.isMapField() && !fieldDescriptor.isRepeated()) { + // Wrapper fields (google.protobuf.{Bool,Int32,...}Value) are recursed into via + // processWrapperRules with the inner "value" field as fieldDescriptor; the value passed + // to evaluate() is still the wrapper Message. CEL transparently unwraps these, but native + // evaluators don't get that for free. Defer wrapper support to a follow-up; CEL handles + // wrappers correctly today. + FieldDescriptor outerDescriptor = valueEvaluator.getDescriptor(); + if (outerDescriptor != null + && outerDescriptor.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + return null; + } return tryBuildScalarRules(fieldDescriptor, rulesBuilder, valueEvaluator); } return null; } - // Phase 1: dispatcher skeleton. Per-kind builders are introduced in subsequent phases. - // Phase 2 wires bool, Phase 3 numeric, Phase 4 enum, Phase 5 bytes, Phase 6 string, - // Phase 7 repeated/map. + // Phase 3 wires numeric, Phase 4 enum, Phase 5 bytes, Phase 6 string, Phase 7 repeated/map. - @SuppressWarnings("unused") private static @Nullable Evaluator tryBuildScalarRules( FieldDescriptor fieldDescriptor, FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { - return null; + switch (fieldDescriptor.getJavaType()) { + case BOOLEAN: + return BoolRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); + default: + return null; + } } @SuppressWarnings("unused") diff --git a/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java new file mode 100644 index 00000000..5abd83a0 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java @@ -0,0 +1,114 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.Violation; +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.BoolRules; +import com.example.noimports.validationtest.ExampleBoolConst; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** + * Validator-level integration tests for {@link BoolRulesEvaluator}. Mirrors the per-rule tests in + * protovalidate-go's {@code native_bool_test.go}, plus an explicit assertion on {@link + * Violation#getRuleValue()} since the conformance suite cannot detect divergence on that field (it + * is not part of the {@code Violation} proto schema — see CHANGELOG Phase 1). + */ +class BoolRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setDisableNativeRules(false).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void boolConstPasses() throws ValidationException { + Validator validator = nativeValidator(); + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(true).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).isEmpty(); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void boolConstFailsAndCarriesExpectedViolationShape() throws ValidationException { + Validator validator = nativeValidator(); + // Default value (false) fails the const=true rule. Bool fields without explicit-presence in + // proto3 still apply rules at default; bool has no IGNORE_IF_ZERO_VALUE behavior here. + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(false).build(); + ValidationResult result = validator.validate(msg); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getViolations()).hasSize(1); + + Violation violation = result.getViolations().get(0); + build.buf.validate.Violation proto = violation.toProto(); + assertThat(proto.getRuleId()).isEqualTo("bool.const"); + assertThat(proto.getMessage()).isEqualTo("must equal true"); + assertThat(proto.getField().getElementsList()).hasSize(1); + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("flag"); + assertThat(proto.getRule().getElementsList()).hasSize(2); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("bool"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // getRuleValue is NOT in the Violation proto — it's only on the Java wrapper. Conformance + // can't catch divergence on this field, so assert it explicitly. See Phase 1 CHANGELOG. + Violation.FieldValue ruleValue = violation.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(true); + FieldDescriptor expectedRuleDesc = + BoolRules.getDescriptor().findFieldByNumber(BoolRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedRuleDesc); + + Violation.FieldValue fieldValue = violation.getFieldValue(); + assertThat(fieldValue).isNotNull(); + assertThat(fieldValue.getValue()).isEqualTo(false); + } + + @Test + void nativeAndCelProducePartiallyEqualViolations() throws ValidationException { + // Same input, both modes — toProto() must match exactly. (rule_value isn't in the proto so + // CEL/native can disagree there; that's covered by the dedicated assertion above.) + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(false).build(); + + ValidationResult nativeResult = nativeValidator().validate(msg); + Validator celValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .build(); + ValidationResult celResult = celValidator.validate(msg); + + assertThat(nativeResult.getViolations()).hasSize(1); + assertThat(celResult.getViolations()).hasSize(1); + assertThat(nativeResult.getViolations().get(0).toProto()) + .isEqualTo(celResult.getViolations().get(0).toProto()); + } + + @Test + void nativeDispatchClearsRuleSoCelDoesNotDuplicateIt() throws ValidationException { + // If the dispatcher failed to clear bool.const on the residual FieldRules, CEL would also + // produce a violation and we'd see two. One violation proves clone-and-clear works. + ExampleBoolConst msg = ExampleBoolConst.newBuilder().setFlag(false).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index cd6b30f7..7a31542e 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -134,3 +134,7 @@ message ExampleImportMessageInMapFieldRule { } ]; } + +message ExampleBoolConst { + bool flag = 1 [(buf.validate.field).bool.const = true]; +} From 88a804c391126878f7d166bc3178a43787e8ae87 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 17:32:46 -0400 Subject: [PATCH 04/31] Add native evaluator for the standard numeric rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all 12 proto numeric kinds (int32, sint32, sfixed32, uint32, fixed32, int64, sint64, sfixed64, uint64, fixed64, float, double) and all scalar numeric rules: gt, gte, lt, lte, const, in, not_in, plus finite for float/double. Range-combo rule ids (.gt_lt[_exclusive], .gte_lte[_exclusive], ...) and violation messages match protovalidate-go's reference. Unsigned correctness for uint32/fixed32/uint64/fixed64 uses Integer.compareUnsigned/Long.compareUnsigned for ordering and Integer.toUnsignedString/Long.toUnsignedString for messages, since Java stores unsigned protobuf scalars in signed primitives. Adds Value.rawValue() so native evaluators can read protobuf-java's underlying scalar (Long for uint64, Integer for int32, ...) without the CEL adaptation that ObjectValue.value(Class) applies — uint64 arrives as Guava UnsignedLong via that path, which doesn't compose with the rule values read off the typed *Rules message. The biggest steady-state benchmarks improve dramatically when native is enabled — validateBenchInt32GT drops 97% in time and 98% in allocation, and buildBenchInt32GT drops 99.7% (5ms → 16μs) because every rule is covered natively and CEL has nothing left to compile. --- .../buf/protovalidate/ListElementValue.java | 5 + .../build/buf/protovalidate/MessageValue.java | 5 + .../build/buf/protovalidate/ObjectValue.java | 5 + .../java/build/buf/protovalidate/Value.java | 16 + .../rules/BoolRulesEvaluator.java | 2 +- .../rules/NumericDescriptors.java | 127 ++++++ .../rules/NumericRulesEvaluator.java | 421 ++++++++++++++++++ .../rules/NumericTypeConfig.java | 259 +++++++++++ .../build/buf/protovalidate/rules/Rules.java | 75 +++- .../rules/NumericRulesEvaluatorTest.java | 169 +++++++ .../proto/validationtest/validationtest.proto | 31 ++ 11 files changed, 1112 insertions(+), 3 deletions(-) create mode 100644 src/main/java/build/buf/protovalidate/rules/NumericDescriptors.java create mode 100644 src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java create mode 100644 src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java create mode 100644 src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java diff --git a/src/main/java/build/buf/protovalidate/ListElementValue.java b/src/main/java/build/buf/protovalidate/ListElementValue.java index fd9d0a11..8e397d32 100644 --- a/src/main/java/build/buf/protovalidate/ListElementValue.java +++ b/src/main/java/build/buf/protovalidate/ListElementValue.java @@ -61,6 +61,11 @@ public T value(Class clazz) { return clazz.cast(ProtoAdapter.scalarToCel(type, value)); } + @Override + public Object rawValue() { + return value; + } + @Override public List repeatedValue() { return Collections.emptyList(); diff --git a/src/main/java/build/buf/protovalidate/MessageValue.java b/src/main/java/build/buf/protovalidate/MessageValue.java index ee15f4a2..fa96e3b8 100644 --- a/src/main/java/build/buf/protovalidate/MessageValue.java +++ b/src/main/java/build/buf/protovalidate/MessageValue.java @@ -51,6 +51,11 @@ public T value(Class clazz) { return clazz.cast(value); } + @Override + public Object rawValue() { + return value; + } + @Override public List repeatedValue() { return Collections.emptyList(); diff --git a/src/main/java/build/buf/protovalidate/ObjectValue.java b/src/main/java/build/buf/protovalidate/ObjectValue.java index 73d320e4..9a53574a 100644 --- a/src/main/java/build/buf/protovalidate/ObjectValue.java +++ b/src/main/java/build/buf/protovalidate/ObjectValue.java @@ -65,6 +65,11 @@ public T value(Class clazz) { return clazz.cast(ProtoAdapter.toCel(fieldDescriptor, value)); } + @Override + public Object rawValue() { + return value; + } + @Override public List repeatedValue() { List out = new ArrayList<>(); diff --git a/src/main/java/build/buf/protovalidate/Value.java b/src/main/java/build/buf/protovalidate/Value.java index d762e824..855db30d 100644 --- a/src/main/java/build/buf/protovalidate/Value.java +++ b/src/main/java/build/buf/protovalidate/Value.java @@ -54,6 +54,22 @@ public interface Value { */ T value(Class clazz); + /** + * Returns the underlying protobuf Java value without any CEL-specific adaptation. + * + *

{@link #value(Class)} routes scalars through {@code ProtoAdapter.toCel}, which converts + * {@code int32→Long}, {@code uint32→UnsignedLong}, {@code float→Double}, {@code bytes→ + * CelByteString}, etc. — appropriate for the CEL evaluation path but lossy for native rule + * evaluators that compare against raw protobuf field values. Native evaluators in {@code + * build.buf.protovalidate.rules} use this method to obtain values they can compare directly with + * the values they read off the typed rule message. + * + * @return The underlying value as protobuf-java provides it. Non-null for all values produced by + * the evaluator pipeline (field reads, list elements, message wrappers — all guarantee a + * value). + */ + Object rawValue(); + /** * Get the underlying value as a list. * diff --git a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java index 543a0f6d..c4aa9061 100644 --- a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java @@ -74,7 +74,7 @@ public boolean tautology() { @Override public List evaluate(Value val, boolean failFast) { - boolean actual = val.value(Boolean.class); + boolean actual = (Boolean) val.rawValue(); if (actual == expected) { return RuleViolation.NO_VIOLATIONS; } diff --git a/src/main/java/build/buf/protovalidate/rules/NumericDescriptors.java b/src/main/java/build/buf/protovalidate/rules/NumericDescriptors.java new file mode 100644 index 00000000..2dd8e488 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/NumericDescriptors.java @@ -0,0 +1,127 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.jspecify.annotations.Nullable; + +/** + * Pre-built {@link RuleSite}s for a single numeric rule type ({@code Int32Rules}, {@code + * UInt64Rules}, {@code FloatRules}, etc.). Built once per type at class-init time so violation + * construction at validation time avoids re-building path-element protos. + * + *

{@code finiteSite} is null for non-float kinds. + */ +final class NumericDescriptors { + final RuleSite gtSite; + final RuleSite gteSite; + final RuleSite ltSite; + final RuleSite lteSite; + final RuleSite constSite; + final RuleSite inSite; + final RuleSite notInSite; + final @Nullable RuleSite finiteSite; + // Leaf descriptors used to look up rule fields on the *Rules message at build time. + final FieldDescriptor gtField; + final FieldDescriptor gteField; + final FieldDescriptor ltField; + final FieldDescriptor lteField; + final FieldDescriptor constField; + final FieldDescriptor inField; + final FieldDescriptor notInField; + final @Nullable FieldDescriptor finiteField; + + private NumericDescriptors( + RuleSite gtSite, + RuleSite gteSite, + RuleSite ltSite, + RuleSite lteSite, + RuleSite constSite, + RuleSite inSite, + RuleSite notInSite, + @Nullable RuleSite finiteSite, + FieldDescriptor gtField, + FieldDescriptor gteField, + FieldDescriptor ltField, + FieldDescriptor lteField, + FieldDescriptor constField, + FieldDescriptor inField, + FieldDescriptor notInField, + @Nullable FieldDescriptor finiteField) { + this.gtSite = gtSite; + this.gteSite = gteSite; + this.ltSite = ltSite; + this.lteSite = lteSite; + this.constSite = constSite; + this.inSite = inSite; + this.notInSite = notInSite; + this.finiteSite = finiteSite; + this.gtField = gtField; + this.gteField = gteField; + this.ltField = ltField; + this.lteField = lteField; + this.constField = constField; + this.inField = inField; + this.notInField = notInField; + this.finiteField = finiteField; + } + + /** + * Builds the descriptor bundle for a numeric rule type. + * + * @param fieldRulesField the {@link FieldDescriptor} of the {@code FieldRules} oneof case (e.g. + * the {@code int32} field on {@code FieldRules}) + * @param rulesDescriptor the {@link Descriptor} of the rules message (e.g. {@code Int32Rules}) + * @param typeName the proto rule prefix used in rule ids (e.g. {@code "int32"}) + * @param hasFinite whether this kind supports the {@code finite} rule (only float/double do) + */ + static NumericDescriptors build( + FieldDescriptor fieldRulesField, + Descriptor rulesDescriptor, + String typeName, + boolean hasFinite) { + FieldDescriptor gt = rulesDescriptor.findFieldByName("gt"); + FieldDescriptor gte = rulesDescriptor.findFieldByName("gte"); + FieldDescriptor lt = rulesDescriptor.findFieldByName("lt"); + FieldDescriptor lte = rulesDescriptor.findFieldByName("lte"); + FieldDescriptor constant = rulesDescriptor.findFieldByName("const"); + FieldDescriptor inField = rulesDescriptor.findFieldByName("in"); + FieldDescriptor notInField = rulesDescriptor.findFieldByName("not_in"); + FieldDescriptor finiteField = hasFinite ? rulesDescriptor.findFieldByName("finite") : null; + return new NumericDescriptors( + // Sites carry rule-id and rule-path for violation building. Where the rule id is + // computed dynamically (gt/gte/lt/lte combine into different ids depending on which + // bounds are active), pass null and let the caller supply per-violation. + RuleSite.of(fieldRulesField, gt, null, null), + RuleSite.of(fieldRulesField, gte, null, null), + RuleSite.of(fieldRulesField, lt, null, null), + RuleSite.of(fieldRulesField, lte, null, null), + RuleSite.of(fieldRulesField, constant, typeName + ".const", null), + RuleSite.of(fieldRulesField, inField, typeName + ".in", null), + RuleSite.of(fieldRulesField, notInField, typeName + ".not_in", null), + finiteField != null + ? RuleSite.of(fieldRulesField, finiteField, typeName + ".finite", "must be finite") + : null, + gt, + gte, + lt, + lte, + constant, + inField, + notInField, + finiteField); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java new file mode 100644 index 00000000..f128343f --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java @@ -0,0 +1,421 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Message; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for the standard numeric rules ({@code gt}, {@code gte}, {@code lt}, {@code + * lte}, {@code const}, {@code in}, {@code not_in}, plus {@code finite} for float/double). Mirrors + * {@code nativeNumericCompare} in protovalidate-go's {@code native_numeric.go}, parameterized over + * the boxed Java numeric type ({@code Integer}, {@code Long}, {@code Float}, {@code Double}). + * + *

Signed vs unsigned semantics are encoded in the supplied {@link NumericTypeConfig}: the + * config's comparator decides ordering and its formatter decides how values render in messages. The + * same {@code Integer}-typed evaluator is shared between {@code int32} (signed comparator, {@code + * String.valueOf} formatter) and {@code uint32} ({@code Integer::compareUnsigned}, {@code + * Integer::toUnsignedString}). + */ +final class NumericRulesEvaluator> implements Evaluator { + + /** Lower bound active on this evaluator. */ + enum LowerBound { + NONE, + GTE, // inclusive + GT // exclusive + } + + /** Upper bound active on this evaluator. */ + enum UpperBound { + NONE, + LT, + LTE + } + + private final RuleBase base; + private final NumericTypeConfig config; + private final @Nullable T constVal; + private final List inVals; + private final List notInVals; + private final @Nullable T loVal; + private final LowerBound lowerKind; + private final @Nullable T hiVal; + private final UpperBound upperKind; + private final boolean finite; + + private NumericRulesEvaluator( + RuleBase base, + NumericTypeConfig config, + @Nullable T constVal, + List inVals, + List notInVals, + @Nullable T loVal, + LowerBound lowerKind, + @Nullable T hiVal, + UpperBound upperKind, + boolean finite) { + this.base = base; + this.config = config; + this.constVal = constVal; + this.inVals = inVals; + this.notInVals = notInVals; + this.loVal = loVal; + this.lowerKind = lowerKind; + this.hiVal = hiVal; + this.upperKind = upperKind; + this.finite = finite; + } + + /** + * Attempts to build a {@link NumericRulesEvaluator} for the rules under {@code rulesField} on + * {@code rulesBuilder}. Returns null when the typed sub-message is unset, has unknown fields, or + * carries no rule we cover. On success, clears the covered fields on the builder so CEL doesn't + * also compile programs for them. + * + * @param rulesField the {@code FieldRules} oneof field for this kind (e.g. the {@code int32} + * field on {@code FieldRules}) + */ + static > @Nullable Evaluator tryBuild( + RuleBase base, + FieldRules.Builder rulesBuilder, + NumericTypeConfig config, + FieldDescriptor rulesField) { + if (!rulesBuilder.hasField(rulesField)) { + return null; + } + Message rulesMsg = (Message) rulesBuilder.getField(rulesField); + if (!rulesMsg.getUnknownFields().asMap().isEmpty()) { + return null; + } + + NumericDescriptors descs = config.descriptors; + Message.Builder typedBuilder = rulesMsg.toBuilder(); + boolean hasRule = false; + + T loVal = null; + LowerBound lowerKind = LowerBound.NONE; + if (rulesMsg.hasField(descs.gtField)) { + lowerKind = LowerBound.GT; + loVal = config.valueClass.cast(rulesMsg.getField(descs.gtField)); + typedBuilder.clearField(descs.gtField); + hasRule = true; + } else if (rulesMsg.hasField(descs.gteField)) { + lowerKind = LowerBound.GTE; + loVal = config.valueClass.cast(rulesMsg.getField(descs.gteField)); + typedBuilder.clearField(descs.gteField); + hasRule = true; + } + + T hiVal = null; + UpperBound upperKind = UpperBound.NONE; + if (rulesMsg.hasField(descs.ltField)) { + upperKind = UpperBound.LT; + hiVal = config.valueClass.cast(rulesMsg.getField(descs.ltField)); + typedBuilder.clearField(descs.ltField); + hasRule = true; + } else if (rulesMsg.hasField(descs.lteField)) { + upperKind = UpperBound.LTE; + hiVal = config.valueClass.cast(rulesMsg.getField(descs.lteField)); + typedBuilder.clearField(descs.lteField); + hasRule = true; + } + + T constVal = null; + if (rulesMsg.hasField(descs.constField)) { + constVal = config.valueClass.cast(rulesMsg.getField(descs.constField)); + typedBuilder.clearField(descs.constField); + hasRule = true; + } + + @SuppressWarnings("unchecked") + List rawInVals = (List) rulesMsg.getField(descs.inField); + List inVals = rawInVals.isEmpty() ? Collections.emptyList() : rawInVals; + if (!inVals.isEmpty()) { + typedBuilder.clearField(descs.inField); + hasRule = true; + } + + @SuppressWarnings("unchecked") + List rawNotInVals = (List) rulesMsg.getField(descs.notInField); + List notInVals = rawNotInVals.isEmpty() ? Collections.emptyList() : rawNotInVals; + if (!notInVals.isEmpty()) { + typedBuilder.clearField(descs.notInField); + hasRule = true; + } + + boolean finite = false; + if (descs.finiteField != null && rulesMsg.hasField(descs.finiteField)) { + finite = (Boolean) rulesMsg.getField(descs.finiteField); + typedBuilder.clearField(descs.finiteField); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setField(rulesField, typedBuilder.build()); + return new NumericRulesEvaluator( + base, config, constVal, inVals, notInVals, loVal, lowerKind, hiVal, upperKind, finite); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + T actual = config.valueClass.cast(val.rawValue()); + List violations = new ArrayList<>(0); + + if (constVal != null && config.comparator.compare(actual, constVal) != 0) { + violations.add( + NativeViolations.newViolation( + config.descriptors.constSite, + null, + "must equal " + config.formatter.apply(constVal), + val, + constVal)); + if (failFast) { + return finalize(violations.subList(0, 1)); + } + } + + if (!inVals.isEmpty() && !containsValue(inVals, actual)) { + violations.add( + NativeViolations.newViolation( + config.descriptors.inSite, + null, + "must be in list " + formatList(inVals), + val, + actual)); + if (failFast) { + return finalize(violations.subList(0, 1)); + } + } + + if (!notInVals.isEmpty() && containsValue(notInVals, actual)) { + violations.add( + NativeViolations.newViolation( + config.descriptors.notInSite, + null, + "must not be in list " + formatList(notInVals), + val, + actual)); + if (failFast) { + return finalize(violations.subList(0, 1)); + } + } + + if (finite && !isFinite(actual)) { + // descriptors.finiteSite is non-null whenever finite==true (set up at builder time). + RuleSite site = + Objects.requireNonNull( + config.descriptors.finiteSite, "finiteSite must be set when finite is true"); + violations.add(NativeViolations.newViolation(site, null, null, val, actual)); + if (failFast) { + return finalize(violations.subList(0, 1)); + } + } + + if (lowerKind != LowerBound.NONE || upperKind != UpperBound.NONE) { + RuleViolation.Builder rangeViolation = buildRangeViolation(val, actual); + if (rangeViolation != null) { + violations.add(rangeViolation); + if (failFast) { + return finalize(violations.subList(0, 1)); + } + } + } + + return finalize(violations); + } + + // --- Per-rule violation builders --- + + /** + * Builds a violation for the lower/upper bound check, or returns null if the value is in range. + * Mirrors {@code nativeNumericCompare.evaluateRange} in protovalidate-go, including the + * exclusive-range semantics where {@code gt > lt} (or equivalents) means "value not in [lt, gt]". + */ + private RuleViolation.@Nullable Builder buildRangeViolation(Value val, T actual) { + boolean isNaN = config.nanFailsRange && isNaN(actual); + if (lowerKind == LowerBound.NONE) { + if (isNaN || aboveHi(actual)) { + return NativeViolations.newViolation( + hiSite(), gtltRule(), "must be " + hiMessage(), val, hiVal); + } + return null; + } + if (upperKind == UpperBound.NONE) { + if (isNaN || belowLo(actual)) { + return NativeViolations.newViolation( + loSite(), gtltRule(), "must be " + loMessage(), val, loVal); + } + return null; + } + boolean failure; + if (isNormalRange()) { + failure = isNaN || aboveHi(actual) || belowLo(actual); + } else { + failure = isNaN || (aboveHi(actual) && belowLo(actual)); + } + if (failure) { + String message = "must be " + loMessage() + " " + conjunction() + " " + hiMessage(); + return NativeViolations.newViolation(loSite(), gtltRule(), message, val, loVal); + } + return null; + } + + // --- Comparison helpers (depend on the comparator from config) --- + + private boolean belowLo(T value) { + int cmp = config.comparator.compare(value, loVal); + return lowerKind == LowerBound.GT ? cmp <= 0 : cmp < 0; + } + + private boolean aboveHi(T value) { + int cmp = config.comparator.compare(value, hiVal); + return upperKind == UpperBound.LT ? cmp >= 0 : cmp > 0; + } + + private boolean isNormalRange() { + // hi >= lo means a normal range. For unsigned kinds this uses the unsigned comparator. + return config.comparator.compare(hiVal, loVal) >= 0; + } + + private boolean containsValue(List list, T value) { + // Use the comparator for equality so unsigned/signed semantics agree. Java's List.contains + // would use Object.equals, which is fine for boxed primitives but we keep a single source of + // truth. + for (T t : list) { + if (config.comparator.compare(t, value) == 0) { + return true; + } + } + return false; + } + + private static boolean isFinite(T value) { + if (value instanceof Float) { + return Float.isFinite(value.floatValue()); + } + if (value instanceof Double) { + return Double.isFinite(value.doubleValue()); + } + // Integer kinds are always finite. + return true; + } + + private static boolean isNaN(T value) { + if (value instanceof Float) { + return Float.isNaN(value.floatValue()); + } + if (value instanceof Double) { + return Double.isNaN(value.doubleValue()); + } + return false; + } + + // --- Rule-id and message helpers (mirror Go's gtltRule / loMessage / hiMessage / conjunction) + // --- + + private RuleSite loSite() { + return lowerKind == LowerBound.GT ? config.descriptors.gtSite : config.descriptors.gteSite; + } + + private RuleSite hiSite() { + return upperKind == UpperBound.LT ? config.descriptors.ltSite : config.descriptors.lteSite; + } + + private String gtRulePrefix() { + return lowerKind == LowerBound.GT ? config.typeName + ".gt" : config.typeName + ".gte"; + } + + private String ltRulePrefix() { + return upperKind == UpperBound.LT ? config.typeName + ".lt" : config.typeName + ".lte"; + } + + /** Combined rule id, e.g. {@code int32.gt_lt_exclusive}. Mirrors Go's {@code gtltRule}. */ + private String gtltRule() { + if (lowerKind == LowerBound.NONE) { + return ltRulePrefix(); + } + String prefix = gtRulePrefix(); + if (upperKind == UpperBound.LT) { + prefix += "_lt"; + if (!isNormalRange()) { + prefix += "_exclusive"; + } + } else if (upperKind == UpperBound.LTE) { + prefix += "_lte"; + if (!isNormalRange()) { + prefix += "_exclusive"; + } + } + return prefix; + } + + private String loMessage() { + String formatted = config.formatter.apply(loVal); + return lowerKind == LowerBound.GT + ? "greater than " + formatted + : "greater than or equal to " + formatted; + } + + private String hiMessage() { + String formatted = config.formatter.apply(hiVal); + return upperKind == UpperBound.LT + ? "less than " + formatted + : "less than or equal to " + formatted; + } + + private String conjunction() { + return isNormalRange() ? "and" : "or"; + } + + private String formatList(List vals) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < vals.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(config.formatter.apply(vals.get(i))); + } + sb.append("]"); + return sb.toString(); + } + + // --- Violation list bookkeeping --- + + private List finalize(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths( + violations, base.getFieldPathElement(), base.getRulePrefixElements()); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java b/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java new file mode 100644 index 00000000..c56d05aa --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java @@ -0,0 +1,259 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.validate.DoubleRules; +import build.buf.validate.FieldRules; +import build.buf.validate.Fixed32Rules; +import build.buf.validate.Fixed64Rules; +import build.buf.validate.FloatRules; +import build.buf.validate.Int32Rules; +import build.buf.validate.Int64Rules; +import build.buf.validate.SFixed32Rules; +import build.buf.validate.SFixed64Rules; +import build.buf.validate.SInt32Rules; +import build.buf.validate.SInt64Rules; +import build.buf.validate.UInt32Rules; +import build.buf.validate.UInt64Rules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.Comparator; +import java.util.function.Function; + +/** + * Per-kind type config for native numeric rule evaluation. Bundles everything that varies between + * proto numeric kinds (int32, uint32, float, etc.): descriptor lookups, the boxed Java type, the + * comparator (signed vs unsigned), the value-to-string formatter, and a flag for whether NaN fails + * range checks. + * + *

One static instance per kind, shared across every {@link NumericRulesEvaluator} for that kind. + */ +final class NumericTypeConfig> { + /** Proto rule prefix used in rule ids ({@code "int32"}, {@code "uint64"}, etc.). */ + final String typeName; + + /** Pre-built rule sites and field descriptors for this kind. */ + final NumericDescriptors descriptors; + + /** Boxed Java class for values of this kind. Used to extract values from {@code Value}. */ + final Class valueClass; + + /** + * Comparator over values of this kind. For signed/float kinds this is the natural order; for + * unsigned kinds it is {@code Integer.compareUnsigned}/{@code Long.compareUnsigned} since Java + * stores unsigned protobuf values in signed primitives. + */ + final Comparator comparator; + + /** + * Renders a value to the string used in violation messages. {@code String::valueOf} for signed + * and float kinds; {@code Integer::toUnsignedString}/{@code Long::toUnsignedString} for unsigned. + * Critical for unsigned kinds — {@code String.valueOf(int)} would print a negative integer for + * values whose unsigned representation exceeds {@code Integer.MAX_VALUE}. + */ + final Function formatter; + + /** + * True for {@code float}/{@code double}: NaN fails range checks (matches CEL semantics). False + * for integer kinds — they have no NaN. + */ + final boolean nanFailsRange; + + private NumericTypeConfig( + String typeName, + NumericDescriptors descriptors, + Class valueClass, + Comparator comparator, + Function formatter, + boolean nanFailsRange) { + this.typeName = typeName; + this.descriptors = descriptors; + this.valueClass = valueClass; + this.comparator = comparator; + this.formatter = formatter; + this.nanFailsRange = nanFailsRange; + } + + // --- Static configs, one per proto numeric kind --- + + private static FieldDescriptor frField(int number) { + return FieldRules.getDescriptor().findFieldByNumber(number); + } + + static final NumericTypeConfig INT32 = + new NumericTypeConfig<>( + "int32", + NumericDescriptors.build( + frField(FieldRules.INT32_FIELD_NUMBER), Int32Rules.getDescriptor(), "int32", false), + Integer.class, + Integer::compare, + String::valueOf, + false); + + static final NumericTypeConfig SINT32 = + new NumericTypeConfig<>( + "sint32", + NumericDescriptors.build( + frField(FieldRules.SINT32_FIELD_NUMBER), + SInt32Rules.getDescriptor(), + "sint32", + false), + Integer.class, + Integer::compare, + String::valueOf, + false); + + static final NumericTypeConfig SFIXED32 = + new NumericTypeConfig<>( + "sfixed32", + NumericDescriptors.build( + frField(FieldRules.SFIXED32_FIELD_NUMBER), + SFixed32Rules.getDescriptor(), + "sfixed32", + false), + Integer.class, + Integer::compare, + String::valueOf, + false); + + static final NumericTypeConfig UINT32 = + new NumericTypeConfig<>( + "uint32", + NumericDescriptors.build( + frField(FieldRules.UINT32_FIELD_NUMBER), + UInt32Rules.getDescriptor(), + "uint32", + false), + Integer.class, + Integer::compareUnsigned, + Integer::toUnsignedString, + false); + + static final NumericTypeConfig FIXED32 = + new NumericTypeConfig<>( + "fixed32", + NumericDescriptors.build( + frField(FieldRules.FIXED32_FIELD_NUMBER), + Fixed32Rules.getDescriptor(), + "fixed32", + false), + Integer.class, + Integer::compareUnsigned, + Integer::toUnsignedString, + false); + + static final NumericTypeConfig INT64 = + new NumericTypeConfig<>( + "int64", + NumericDescriptors.build( + frField(FieldRules.INT64_FIELD_NUMBER), Int64Rules.getDescriptor(), "int64", false), + Long.class, + Long::compare, + String::valueOf, + false); + + static final NumericTypeConfig SINT64 = + new NumericTypeConfig<>( + "sint64", + NumericDescriptors.build( + frField(FieldRules.SINT64_FIELD_NUMBER), + SInt64Rules.getDescriptor(), + "sint64", + false), + Long.class, + Long::compare, + String::valueOf, + false); + + static final NumericTypeConfig SFIXED64 = + new NumericTypeConfig<>( + "sfixed64", + NumericDescriptors.build( + frField(FieldRules.SFIXED64_FIELD_NUMBER), + SFixed64Rules.getDescriptor(), + "sfixed64", + false), + Long.class, + Long::compare, + String::valueOf, + false); + + static final NumericTypeConfig UINT64 = + new NumericTypeConfig<>( + "uint64", + NumericDescriptors.build( + frField(FieldRules.UINT64_FIELD_NUMBER), + UInt64Rules.getDescriptor(), + "uint64", + false), + Long.class, + Long::compareUnsigned, + Long::toUnsignedString, + false); + + static final NumericTypeConfig FIXED64 = + new NumericTypeConfig<>( + "fixed64", + NumericDescriptors.build( + frField(FieldRules.FIXED64_FIELD_NUMBER), + Fixed64Rules.getDescriptor(), + "fixed64", + false), + Long.class, + Long::compareUnsigned, + Long::toUnsignedString, + false); + + static final NumericTypeConfig FLOAT = + new NumericTypeConfig<>( + "float", + NumericDescriptors.build( + frField(FieldRules.FLOAT_FIELD_NUMBER), FloatRules.getDescriptor(), "float", true), + Float.class, + Float::compare, + NumericTypeConfig::floatFormatter, + true); + + static final NumericTypeConfig DOUBLE = + new NumericTypeConfig<>( + "double", + NumericDescriptors.build( + frField(FieldRules.DOUBLE_FIELD_NUMBER), DoubleRules.getDescriptor(), "double", true), + Double.class, + Double::compare, + NumericTypeConfig::floatFormatter, + true); + + public static String floatFormatter(Object obj) { + if (obj instanceof Float) { + // if the float is a whole number, don't print the decimal + Float f = (Float) obj; + float f2 = f.intValue(); + if (f2 == f) { + return String.valueOf(f.intValue()); + } + return String.valueOf(f); + } + if (obj instanceof Double) { + // if the float is a whole number, don't print the decimal + Double d = (Double) obj; + double d2 = d.intValue(); + if (d2 == d) { + return String.valueOf(d.intValue()); + } + return String.valueOf(d); + } + return String.valueOf(obj); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index a8bccf0a..c877bb12 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -77,20 +77,91 @@ private Rules() {} return null; } - // Phase 3 wires numeric, Phase 4 enum, Phase 5 bytes, Phase 6 string, Phase 7 repeated/map. + // Phase 4 wires enum, Phase 5 bytes, Phase 6 string, Phase 7 repeated/map. private static @Nullable Evaluator tryBuildScalarRules( FieldDescriptor fieldDescriptor, FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { + RuleBase base = RuleBase.of(valueEvaluator); switch (fieldDescriptor.getJavaType()) { case BOOLEAN: - return BoolRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); + return BoolRulesEvaluator.tryBuild(base, rulesBuilder); + case INT: + case LONG: + case FLOAT: + case DOUBLE: + NumericTypeConfig config = numericConfigFor(fieldDescriptor); + if (config == null) { + return null; + } + return numericTryBuild(base, rulesBuilder, config); default: return null; } } + private static @Nullable NumericTypeConfig numericConfigFor(FieldDescriptor fd) { + switch (fd.getType()) { + case INT32: + return NumericTypeConfig.INT32; + case SINT32: + return NumericTypeConfig.SINT32; + case SFIXED32: + return NumericTypeConfig.SFIXED32; + case UINT32: + return NumericTypeConfig.UINT32; + case FIXED32: + return NumericTypeConfig.FIXED32; + case INT64: + return NumericTypeConfig.INT64; + case SINT64: + return NumericTypeConfig.SINT64; + case SFIXED64: + return NumericTypeConfig.SFIXED64; + case UINT64: + return NumericTypeConfig.UINT64; + case FIXED64: + return NumericTypeConfig.FIXED64; + case FLOAT: + return NumericTypeConfig.FLOAT; + case DOUBLE: + return NumericTypeConfig.DOUBLE; + default: + return null; + } + } + + /** + * Helper that captures the {@code } on {@link NumericTypeConfig} so {@link + * NumericRulesEvaluator#tryBuild} compiles cleanly. The unchecked cast is sound because the + * config's generic parameter is the same as the evaluator's. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static @Nullable Evaluator numericTryBuild( + RuleBase base, FieldRules.Builder rulesBuilder, NumericTypeConfig config) { + FieldDescriptor rulesField = + FieldRules.getDescriptor().findFieldByNumber(rulesFieldNumberFor(config)); + return NumericRulesEvaluator.tryBuild( + base, rulesBuilder, (NumericTypeConfig) config, rulesField); + } + + private static int rulesFieldNumberFor(NumericTypeConfig config) { + if (config == NumericTypeConfig.INT32) return FieldRules.INT32_FIELD_NUMBER; + if (config == NumericTypeConfig.SINT32) return FieldRules.SINT32_FIELD_NUMBER; + if (config == NumericTypeConfig.SFIXED32) return FieldRules.SFIXED32_FIELD_NUMBER; + if (config == NumericTypeConfig.UINT32) return FieldRules.UINT32_FIELD_NUMBER; + if (config == NumericTypeConfig.FIXED32) return FieldRules.FIXED32_FIELD_NUMBER; + if (config == NumericTypeConfig.INT64) return FieldRules.INT64_FIELD_NUMBER; + if (config == NumericTypeConfig.SINT64) return FieldRules.SINT64_FIELD_NUMBER; + if (config == NumericTypeConfig.SFIXED64) return FieldRules.SFIXED64_FIELD_NUMBER; + if (config == NumericTypeConfig.UINT64) return FieldRules.UINT64_FIELD_NUMBER; + if (config == NumericTypeConfig.FIXED64) return FieldRules.FIXED64_FIELD_NUMBER; + if (config == NumericTypeConfig.FLOAT) return FieldRules.FLOAT_FIELD_NUMBER; + if (config == NumericTypeConfig.DOUBLE) return FieldRules.DOUBLE_FIELD_NUMBER; + throw new IllegalArgumentException("unknown numeric config"); + } + @SuppressWarnings("unused") private static @Nullable Evaluator tryBuildRepeatedRules( FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { diff --git a/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java new file mode 100644 index 00000000..484efb35 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java @@ -0,0 +1,169 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.Violation; +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.DoubleRules; +import build.buf.validate.Int32Rules; +import build.buf.validate.UInt32Rules; +import com.example.noimports.validationtest.ExampleDoubleIn; +import com.example.noimports.validationtest.ExampleFloatFinite; +import com.example.noimports.validationtest.ExampleInt32Const; +import com.example.noimports.validationtest.ExampleInt32GtLt; +import com.example.noimports.validationtest.ExampleUint32Gt; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** + * Validator-level tests for {@link NumericRulesEvaluator}. Per-kind comparison correctness is + * already covered comprehensively by the conformance suite (44 cases × 12 kinds); these tests focus + * on the things conformance can't catch: + * + *

+ */ +class NumericRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setDisableNativeRules(false).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void int32ConstFailsAndCarriesExpectedShape() throws ValidationException { + // Default 0 != const 5. + ExampleInt32Const msg = ExampleInt32Const.newBuilder().setVal(0).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + + Violation v = result.getViolations().get(0); + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("int32.const"); + assertThat(proto.getMessage()).isEqualTo("must equal 5"); + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("val"); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("int32"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // Action item from Phase 1 / Task #3: rule_value isn't in the Violation proto, so the + // conformance suite can't catch divergence on it. Assert directly here. + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(5); + FieldDescriptor expectedDesc = + Int32Rules.getDescriptor().findFieldByNumber(Int32Rules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void int32GtLtRangeProducesCombinedRuleId() throws ValidationException { + // Default 0 violates gt=0 (lower bound). Combined gt+lt produces "int32.gt_lt" rule id with + // a single combined message. + ExampleInt32GtLt msg = ExampleInt32GtLt.newBuilder().setVal(0).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("int32.gt_lt"); + assertThat(proto.getMessage()).isEqualTo("must be greater than 0 and less than 10"); + } + + @Test + void uint32UnsignedComparisonHandlesValuesAboveSignedMax() throws ValidationException { + // Rule: gt = 2147483648 (which is Integer.MAX_VALUE + 1 as unsigned). A naive signed compare + // would interpret this threshold as -2147483648 and accept any positive int. With unsigned + // semantics, 1 must NOT satisfy gt=2147483648. + ExampleUint32Gt msg = ExampleUint32Gt.newBuilder().setVal(1).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("uint32.gt"); + assertThat(proto.getMessage()).isEqualTo("must be greater than 2147483648"); + + Violation.FieldValue ruleValue = result.getViolations().get(0).getRuleValue(); + assertThat(ruleValue).isNotNull(); + // Stored as Java's signed Integer with the bit pattern of unsigned 2147483648. + assertThat(ruleValue.getValue()).isEqualTo(Integer.MIN_VALUE); + FieldDescriptor expectedDesc = + UInt32Rules.getDescriptor().findFieldByNumber(UInt32Rules.GT_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void uint32UnsignedComparisonAcceptsValueAboveThreshold() throws ValidationException { + // 3000000000 (unsigned) must satisfy gt=2147483648 (unsigned). + ExampleUint32Gt msg = ExampleUint32Gt.newBuilder().setVal((int) 3_000_000_000L).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getViolations()).isEmpty(); + } + + @Test + void floatFiniteFailsForNaNAndInf() throws ValidationException { + Validator v = nativeValidator(); + assertThat(v.validate(ExampleFloatFinite.newBuilder().setVal(Float.NaN).build()).isSuccess()) + .isFalse(); + assertThat( + v.validate(ExampleFloatFinite.newBuilder().setVal(Float.POSITIVE_INFINITY).build()) + .isSuccess()) + .isFalse(); + assertThat(v.validate(ExampleFloatFinite.newBuilder().setVal(1.0f).build()).isSuccess()) + .isTrue(); + } + + @Test + void doubleInRuleValueShape() throws ValidationException { + // 0.0 not in [1.5, 2.5]. + ExampleDoubleIn msg = ExampleDoubleIn.newBuilder().setVal(0.0).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + assertThat(v.toProto().getRuleId()).isEqualTo("double.in"); + + // For in-list violations the rule_value is the failing value (matches Go's behavior). + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(0.0); + FieldDescriptor expectedDesc = + DoubleRules.getDescriptor().findFieldByNumber(DoubleRules.IN_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void nativeAndCelProduceEqualViolationProtos() throws ValidationException { + // Same input, both modes — toProto() must match exactly. Excludes rule_value since that's + // not in the proto. + ExampleInt32GtLt msg = ExampleInt32GtLt.newBuilder().setVal(0).build(); + + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .build(); + + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index 7a31542e..b83b8669 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -138,3 +138,34 @@ message ExampleImportMessageInMapFieldRule { message ExampleBoolConst { bool flag = 1 [(buf.validate.field).bool.const = true]; } + +message ExampleInt32Const { + int32 val = 1 [(buf.validate.field).int32.const = 5]; +} + +message ExampleInt32GtLt { + int32 val = 1 [(buf.validate.field).int32 = { + gt: 0 + lt: 10 + }]; +} + +message ExampleUint32Gt { + // Threshold above Integer.MAX_VALUE — verifies unsigned comparison: a uint32 value of + // 3000000000 must satisfy gt=2147483648, even though both render as negative when + // interpreted as signed int. + uint32 val = 1 [(buf.validate.field).uint32.gt = 2147483648]; +} + +message ExampleFloatFinite { + float val = 1 [(buf.validate.field).float.finite = true]; +} + +message ExampleDoubleIn { + double val = 1 [(buf.validate.field).double = { + in: [ + 1.5, + 2.5 + ] + }]; +} From cd49558551a43ceee5c62cdee8057f9497424381 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 18:15:00 -0400 Subject: [PATCH 05/31] Add native evaluator for enum const/in/not_in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the three enum rules that aren't already handled by the existing EnumEvaluator (defined_only). Both evaluators compose on the same field when both rules are set — protovalidate's clone-and-clear contract leaves defined_only untouched on the residual FieldRules so the existing path keeps working. Also adds bench fixtures BenchBoolConst and BenchEnumRules so Phase 2 and this phase have direct measurement targets. Previously neither phase had a bool-only or enum-only bench message and the JMH numbers were dominated by dispatcher overhead. With these fixtures, native vs CEL shows ~85% and ~90% time reduction on the steady-state path. While here, simplifies getUnknownFields().asMap().isEmpty() to just isEmpty() in the bool and numeric evaluators — UnknownFieldSet has the method directly. --- .../benchmarks/BenchFixtures.java | 13 ++ .../benchmarks/ValidationBenchmark.java | 18 ++ .../src/jmh/proto/bench/v1/native_bench.proto | 25 +++ .../rules/BoolRulesEvaluator.java | 2 +- .../rules/EnumRulesEvaluator.java | 203 ++++++++++++++++++ .../rules/NumericRulesEvaluator.java | 2 +- .../build/buf/protovalidate/rules/Rules.java | 4 +- .../rules/EnumRulesEvaluatorTest.java | 92 ++++++++ .../proto/validationtest/validationtest.proto | 20 ++ 9 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java create mode 100644 src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java index 7f5b57ce..0b8c9c33 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java @@ -14,10 +14,13 @@ package build.buf.protovalidate.benchmarks; +import build.buf.protovalidate.benchmarks.gen.BenchBoolConst; import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; import build.buf.protovalidate.benchmarks.gen.BenchEnum; +import build.buf.protovalidate.benchmarks.gen.BenchEnumRules; import build.buf.protovalidate.benchmarks.gen.BenchGT; import build.buf.protovalidate.benchmarks.gen.BenchMap; +import build.buf.protovalidate.benchmarks.gen.BenchPhaseEnum; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedMessage; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalar; @@ -200,4 +203,14 @@ static MultiRule multiRuleNoError() { static MultiRule multiRuleError() { return MultiRule.newBuilder().setMany(1).build(); } + + /** Phase 2 measurement target — exercises BoolRulesEvaluator on bool.const. */ + static BenchBoolConst benchBoolConst() { + return BenchBoolConst.newBuilder().setFlag(true).build(); + } + + /** Phase 4 measurement target — exercises EnumRulesEvaluator on enum.in. */ + static BenchEnumRules benchEnumRules() { + return BenchEnumRules.newBuilder().setVal(BenchPhaseEnum.BENCH_PHASE_ENUM_TWO).build(); + } } diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index 3d06d97b..6dc7c6c9 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -17,7 +17,9 @@ import build.buf.protovalidate.Config; import build.buf.protovalidate.Validator; import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.benchmarks.gen.BenchBoolConst; import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchEnumRules; import build.buf.protovalidate.benchmarks.gen.BenchGT; import build.buf.protovalidate.benchmarks.gen.BenchMap; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; @@ -88,6 +90,8 @@ public class ValidationBenchmark { private WrapperTesting wrapperTesting; private MultiRule multiRuleNoError; private MultiRule multiRuleError; + private BenchBoolConst benchBoolConst; + private BenchEnumRules benchEnumRules; @Setup public void setup() throws ValidationException { @@ -131,6 +135,8 @@ public void setup() throws ValidationException { wrapperTesting = BenchFixtures.wrapperTesting(); multiRuleNoError = BenchFixtures.multiRuleNoError(); multiRuleError = BenchFixtures.multiRuleError(); + benchBoolConst = BenchFixtures.benchBoolConst(); + benchEnumRules = BenchFixtures.benchEnumRules(); // Warm evaluator cache for steady-state benchmarks. validator.validate(simple); @@ -150,6 +156,8 @@ public void setup() throws ValidationException { validator.validate(wrapperTesting); validator.validate(multiRuleNoError); validator.validate(multiRuleError); + validator.validate(benchBoolConst); + validator.validate(benchEnumRules); } // --- Existing regression-guard benchmarks --- @@ -240,4 +248,14 @@ public void validateMultiRuleNoError(Blackhole bh) throws ValidationException { public void validateMultiRuleError(Blackhole bh) throws ValidationException { bh.consume(validator.validate(multiRuleError)); } + + @Benchmark + public void validateBenchBoolConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchBoolConst)); + } + + @Benchmark + public void validateBenchEnumRules(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchEnumRules)); + } } diff --git a/benchmarks/src/jmh/proto/bench/v1/native_bench.proto b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto index 368db497..f35463f9 100644 --- a/benchmarks/src/jmh/proto/bench/v1/native_bench.proto +++ b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto @@ -197,4 +197,29 @@ message MultiRule { (buf.validate.field).int64.const = 10, (buf.validate.field).int64.gt = 5 ]; +} + +// Phase 2 measurement target. Single bool field with a const rule so the bench has a direct +// hit on BoolRulesEvaluator. +message BenchBoolConst { + bool flag = 1 [(buf.validate.field).bool.const = true]; +} + +enum BenchPhaseEnum { + BENCH_PHASE_ENUM_UNSPECIFIED = 0; + BENCH_PHASE_ENUM_ONE = 1; + BENCH_PHASE_ENUM_TWO = 2; + BENCH_PHASE_ENUM_THREE = 3; +} + +// Phase 4 measurement target. Single enum field with const + in rules so the bench has a direct +// hit on EnumRulesEvaluator. +message BenchEnumRules { + BenchPhaseEnum val = 1 [(buf.validate.field).enum = { + in: [ + 1, + 2, + 3 + ] + }]; } \ No newline at end of file diff --git a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java index c4aa9061..782581da 100644 --- a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java @@ -56,7 +56,7 @@ private BoolRulesEvaluator(RuleBase base, boolean expected) { return null; } BoolRules boolRules = rulesBuilder.getBool(); - if (!boolRules.getUnknownFields().asMap().isEmpty()) { + if (!boolRules.getUnknownFields().isEmpty()) { return null; } if (!boolRules.hasConst()) { diff --git a/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java new file mode 100644 index 00000000..875f8637 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java @@ -0,0 +1,203 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.validate.EnumRules; +import build.buf.validate.FieldRules; +import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for enum {@code const}/{@code in}/{@code not_in}. The {@code defined_only} rule + * is handled separately by the existing {@link build.buf.protovalidate.EnumEvaluator}; both can be + * active simultaneously and the {@link build.buf.protovalidate.ValueEvaluator} runs them in order. + */ +final class EnumRulesEvaluator implements Evaluator { + private static final FieldDescriptor ENUM_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.ENUM_FIELD_NUMBER); + private static final FieldDescriptor CONST_DESC = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.CONST_FIELD_NUMBER); + private static final FieldDescriptor IN_DESC = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.IN_FIELD_NUMBER); + private static final FieldDescriptor NOT_IN_DESC = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.NOT_IN_FIELD_NUMBER); + private static final RuleSite CONST_SITE = + RuleSite.of(ENUM_RULES_DESC, CONST_DESC, "enum.const", null); + private static final RuleSite IN_SITE = RuleSite.of(ENUM_RULES_DESC, IN_DESC, "enum.in", null); + private static final RuleSite NOT_IN_SITE = + RuleSite.of(ENUM_RULES_DESC, NOT_IN_DESC, "enum.not_in", null); + + private final RuleBase base; + private final @Nullable Integer constVal; + private final List inVals; + private final List notInVals; + + private EnumRulesEvaluator( + RuleBase base, @Nullable Integer constVal, List inVals, List notInVals) { + this.base = base; + this.constVal = constVal; + this.inVals = inVals; + this.notInVals = notInVals; + } + + /** + * Builds a {@link EnumRulesEvaluator} for the {@code const}/{@code in}/{@code not_in} rules on + * the supplied {@code FieldRules.Builder}'s enum sub-message. Returns null when the enum sub- + * message is unset, has unknown fields, or has none of the covered rules. The {@code + * defined_only} field is left untouched on the residual so the existing {@link + * build.buf.protovalidate.EnumEvaluator} continues to handle it. + */ + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasEnum()) { + return null; + } + EnumRules enumRules = rulesBuilder.getEnum(); + if (!enumRules.getUnknownFields().isEmpty()) { + return null; + } + + EnumRules.Builder eb = enumRules.toBuilder(); + boolean hasRule = false; + + Integer constVal = null; + if (enumRules.hasConst()) { + constVal = enumRules.getConst(); + eb.clearConst(); + hasRule = true; + } + + List inVals = + enumRules.getInList().isEmpty() + ? Collections.emptyList() + : new ArrayList<>(enumRules.getInList()); + if (!inVals.isEmpty()) { + eb.clearIn(); + hasRule = true; + } + + List notInVals = + enumRules.getNotInList().isEmpty() + ? Collections.emptyList() + : new ArrayList<>(enumRules.getNotInList()); + if (!notInVals.isEmpty()) { + eb.clearNotIn(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setEnum(eb.build()); + return new EnumRulesEvaluator(base, constVal, inVals, notInVals); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + int actual = enumNumber(val.rawValue()); + List violations = null; + + if (constVal != null && actual != constVal) { + RuleViolation.Builder b = + NativeViolations.newViolation(CONST_SITE, null, "must equal " + constVal, val, constVal); + violations = add(violations, b); + if (failFast) { + return done(violations); + } + } + + if (!inVals.isEmpty() && !inVals.contains(actual)) { + RuleViolation.Builder b = + NativeViolations.newViolation( + IN_SITE, null, "must be in list " + formatList(inVals), val, actual); + violations = add(violations, b); + if (failFast) { + return done(violations); + } + } + + if (!notInVals.isEmpty() && notInVals.contains(actual)) { + RuleViolation.Builder b = + NativeViolations.newViolation( + NOT_IN_SITE, null, "must not be in list " + formatList(notInVals), val, actual); + violations = add(violations, b); + if (failFast) { + return done(violations); + } + } + + return done(violations); + } + + /** + * Extracts the enum's numeric value from {@link Value#rawValue()}. Java protobuf normally returns + * an {@link EnumValueDescriptor}, but unknown enum values may surface as {@link Integer} + * depending on the proto edition; handle both. + */ + private static int enumNumber(Object raw) { + if (raw instanceof EnumValueDescriptor) { + return ((EnumValueDescriptor) raw).getNumber(); + } + if (raw instanceof Integer) { + return (Integer) raw; + } + if (raw instanceof Long) { + return ((Long) raw).intValue(); + } + throw new IllegalStateException( + "unexpected enum value representation: " + raw.getClass().getName()); + } + + private static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + private List done(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths( + violations, base.getFieldPathElement(), base.getRulePrefixElements()); + } + + private static String formatList(List vals) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < vals.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(vals.get(i)); + } + sb.append("]"); + return sb.toString(); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java index f128343f..fa836e5f 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java @@ -107,7 +107,7 @@ private NumericRulesEvaluator( return null; } Message rulesMsg = (Message) rulesBuilder.getField(rulesField); - if (!rulesMsg.getUnknownFields().asMap().isEmpty()) { + if (!rulesMsg.getUnknownFields().isEmpty()) { return null; } diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index c877bb12..8f0dfe32 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -77,7 +77,7 @@ private Rules() {} return null; } - // Phase 4 wires enum, Phase 5 bytes, Phase 6 string, Phase 7 repeated/map. + // Phase 5 wires bytes, Phase 6 string, Phase 7 repeated/map. private static @Nullable Evaluator tryBuildScalarRules( FieldDescriptor fieldDescriptor, @@ -87,6 +87,8 @@ private Rules() {} switch (fieldDescriptor.getJavaType()) { case BOOLEAN: return BoolRulesEvaluator.tryBuild(base, rulesBuilder); + case ENUM: + return EnumRulesEvaluator.tryBuild(base, rulesBuilder); case INT: case LONG: case FLOAT: diff --git a/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java new file mode 100644 index 00000000..e41d8e05 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java @@ -0,0 +1,92 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.Violation; +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.EnumRules; +import com.example.noimports.validationtest.ExampleColor; +import com.example.noimports.validationtest.ExampleEnumConst; +import com.example.noimports.validationtest.ExampleEnumIn; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** Validator-level tests for {@link EnumRulesEvaluator}. */ +class EnumRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setDisableNativeRules(false).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void enumConstFailsAndCarriesExpectedShape() throws ValidationException { + // Default UNSPECIFIED (0) != const 2 (GREEN). + ExampleEnumConst msg = ExampleEnumConst.newBuilder().build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("enum.const"); + assertThat(proto.getMessage()).isEqualTo("must equal 2"); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("enum"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // rule_value isn't in the Violation proto — assert it directly here. See Phase 1 CHANGELOG. + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(2); + FieldDescriptor expectedDesc = + EnumRules.getDescriptor().findFieldByNumber(EnumRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void enumInFailsForValueNotInList() throws ValidationException { + // Default UNSPECIFIED (0) not in [1, 3]. + ExampleEnumIn msg = ExampleEnumIn.newBuilder().build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("enum.in"); + assertThat(proto.getMessage()).isEqualTo("must be in list [1, 3]"); + } + + @Test + void enumInPassesForValueInList() throws ValidationException { + ExampleEnumIn msg = ExampleEnumIn.newBuilder().setVal(ExampleColor.EXAMPLE_COLOR_RED).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + ExampleEnumConst msg = ExampleEnumConst.newBuilder().build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index b83b8669..1d962ebe 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -169,3 +169,23 @@ message ExampleDoubleIn { ] }]; } + +enum ExampleColor { + EXAMPLE_COLOR_UNSPECIFIED = 0; + EXAMPLE_COLOR_RED = 1; + EXAMPLE_COLOR_GREEN = 2; + EXAMPLE_COLOR_BLUE = 3; +} + +message ExampleEnumConst { + ExampleColor val = 1 [(buf.validate.field).enum.const = 2]; +} + +message ExampleEnumIn { + ExampleColor val = 1 [(buf.validate.field).enum = { + in: [ + 1, + 3 + ] + }]; +} From d7662e6acd9bd4ca40d2fed91017087bea4fd8c4 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 18:30:19 -0400 Subject: [PATCH 06/31] Add native evaluator for the standard bytes rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers const, len, min_len, max_len, pattern, prefix, suffix, contains, in, not_in, plus the well-known size-only formats ip (4 or 16 bytes), ipv4 (4 bytes), ipv6 (16 bytes), uuid (16 bytes) — none of those require actual parsing per protovalidate's spec. Pattern matching uses re2j to stay byte-compatible with the CEL path, and throws ExecutionException on non-UTF-8 input to match Go's RuntimeError contract — the conformance suite expects pattern checks to fail loudly rather than silently substituting characters. ByteString doesn't expose a contains() method, so there's a small in-class implementation. Hex formatter mirrors Go's fmt.Sprintf("%x") output for byte slices, used in const/prefix/suffix/contains violation messages. validateTestByteMatching drops 92% in time and 97% in allocation when native is enabled. validateBenchComplexSchema is now down 73% in time vs the Phase 0 baseline (numerics + bytes both native). --- .../rules/BytesRulesEvaluator.java | 552 ++++++++++++++++++ .../build/buf/protovalidate/rules/Rules.java | 4 +- .../rules/BytesRulesEvaluatorTest.java | 128 ++++ .../proto/validationtest/validationtest.proto | 12 + 4 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java create mode 100644 src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java diff --git a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java new file mode 100644 index 00000000..0490936f --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java @@ -0,0 +1,552 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.protovalidate.exceptions.ExecutionException; +import build.buf.validate.BytesRules; +import build.buf.validate.FieldRules; +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for the standard bytes rules: {@code const}, {@code len}, {@code min_len}, + * {@code max_len}, {@code pattern}, {@code prefix}, {@code suffix}, {@code contains}, {@code in}, + * {@code not_in}, plus the well-known size-only formats {@code ip}, {@code ipv4}, {@code ipv6}, + * {@code uuid}. Mirrors {@code nativeBytesEval} in protovalidate-go's {@code native_bytes.go}. + */ +final class BytesRulesEvaluator implements Evaluator { + + /** Well-known bytes format constraint — purely size-based per protovalidate spec. */ + private enum WellKnown { + IP( + "bytes.ip", + "must be a valid IP address", + "bytes.ip_empty", + "value is empty, which is not a valid IP address", + Arrays.asList(4, 16), + BytesRules.IP_FIELD_NUMBER), + IPV4( + "bytes.ipv4", + "must be a valid IPv4 address", + "bytes.ipv4_empty", + "value is empty, which is not a valid IPv4 address", + Collections.singletonList(4), + BytesRules.IPV4_FIELD_NUMBER), + IPV6( + "bytes.ipv6", + "must be a valid IPv6 address", + "bytes.ipv6_empty", + "value is empty, which is not a valid IPv6 address", + Collections.singletonList(16), + BytesRules.IPV6_FIELD_NUMBER), + UUID( + "bytes.uuid", + "must be a valid UUID", + "bytes.uuid_empty", + "value is empty, which is not a valid UUID", + Collections.singletonList(16), + BytesRules.UUID_FIELD_NUMBER); + + final RuleSite site; + final RuleSite emptySite; + final List validSizes; + final FieldDescriptor field; + + WellKnown( + String ruleId, + String message, + String emptyRuleId, + String emptyMessage, + List validSizes, + int fieldNumber) { + FieldDescriptor leaf = BytesRules.getDescriptor().findFieldByNumber(fieldNumber); + this.field = leaf; + this.site = RuleSite.of(BYTES_RULES_DESC, leaf, ruleId, message); + this.emptySite = RuleSite.of(BYTES_RULES_DESC, leaf, emptyRuleId, emptyMessage); + this.validSizes = Collections.unmodifiableList(validSizes); + } + + boolean sizeIsValid(int size) { + return validSizes.contains(size); + } + } + + private static final FieldDescriptor BYTES_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.BYTES_FIELD_NUMBER); + + private static final RuleSite CONST_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.CONST_FIELD_NUMBER), + "bytes.const", + null); + private static final RuleSite LEN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.LEN_FIELD_NUMBER), + "bytes.len", + null); + private static final RuleSite MIN_LEN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.MIN_LEN_FIELD_NUMBER), + "bytes.min_len", + null); + private static final RuleSite MAX_LEN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.MAX_LEN_FIELD_NUMBER), + "bytes.max_len", + null); + private static final RuleSite PATTERN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.PATTERN_FIELD_NUMBER), + "bytes.pattern", + null); + private static final RuleSite PREFIX_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.PREFIX_FIELD_NUMBER), + "bytes.prefix", + null); + private static final RuleSite SUFFIX_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.SUFFIX_FIELD_NUMBER), + "bytes.suffix", + null); + private static final RuleSite CONTAINS_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.CONTAINS_FIELD_NUMBER), + "bytes.contains", + null); + private static final RuleSite IN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.IN_FIELD_NUMBER), + "bytes.in", + null); + private static final RuleSite NOT_IN_SITE = + RuleSite.of( + BYTES_RULES_DESC, + BytesRules.getDescriptor().findFieldByNumber(BytesRules.NOT_IN_FIELD_NUMBER), + "bytes.not_in", + null); + + private final RuleBase base; + private final @Nullable ByteString constVal; + private final @Nullable Long exactLen; + private final @Nullable Long minLen; + private final @Nullable Long maxLen; + private final @Nullable Pattern pattern; + private final @Nullable String patternStr; + private final @Nullable ByteString prefix; + private final @Nullable ByteString suffix; + private final @Nullable ByteString contains; + private final List inVals; + private final List notInVals; + private final @Nullable WellKnown wellKnown; + + private BytesRulesEvaluator( + RuleBase base, + @Nullable ByteString constVal, + @Nullable Long exactLen, + @Nullable Long minLen, + @Nullable Long maxLen, + @Nullable Pattern pattern, + @Nullable String patternStr, + @Nullable ByteString prefix, + @Nullable ByteString suffix, + @Nullable ByteString contains, + List inVals, + List notInVals, + @Nullable WellKnown wellKnown) { + this.base = base; + this.constVal = constVal; + this.exactLen = exactLen; + this.minLen = minLen; + this.maxLen = maxLen; + this.pattern = pattern; + this.patternStr = patternStr; + this.prefix = prefix; + this.suffix = suffix; + this.contains = contains; + this.inVals = inVals; + this.notInVals = notInVals; + this.wellKnown = wellKnown; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasBytes()) { + return null; + } + BytesRules rules = rulesBuilder.getBytes(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + BytesRules.Builder bb = rules.toBuilder(); + boolean hasRule = false; + + WellKnown wellKnown = null; + // Mirror Go's switch — earlier cases win. Setting ip=true takes precedence over + // ipv4/ipv6/uuid if multiple are set; protovalidate considers that a misconfiguration but + // we follow Go's order to keep behavior identical. + if (rules.getIp()) { + wellKnown = WellKnown.IP; + bb.clearIp(); + hasRule = true; + } else if (rules.getIpv4()) { + wellKnown = WellKnown.IPV4; + bb.clearIpv4(); + hasRule = true; + } else if (rules.getIpv6()) { + wellKnown = WellKnown.IPV6; + bb.clearIpv6(); + hasRule = true; + } else if (rules.getUuid()) { + wellKnown = WellKnown.UUID; + bb.clearUuid(); + hasRule = true; + } + + ByteString constVal = null; + if (rules.hasConst()) { + constVal = rules.getConst(); + bb.clearConst(); + hasRule = true; + } + + Long exactLen = null; + if (rules.hasLen()) { + exactLen = rules.getLen(); + bb.clearLen(); + hasRule = true; + } + + Long minLen = null; + if (rules.hasMinLen()) { + minLen = rules.getMinLen(); + bb.clearMinLen(); + hasRule = true; + } + + Long maxLen = null; + if (rules.hasMaxLen()) { + maxLen = rules.getMaxLen(); + bb.clearMaxLen(); + hasRule = true; + } + + Pattern compiledPattern = null; + String patternStr = null; + if (rules.hasPattern()) { + patternStr = rules.getPattern(); + try { + compiledPattern = Pattern.compile(patternStr); + } catch (PatternSyntaxException e) { + // Bail to CEL — it produces the same compilation error. + return null; + } + bb.clearPattern(); + hasRule = true; + } + + ByteString prefix = null; + if (rules.hasPrefix()) { + prefix = rules.getPrefix(); + bb.clearPrefix(); + hasRule = true; + } + + ByteString suffix = null; + if (rules.hasSuffix()) { + suffix = rules.getSuffix(); + bb.clearSuffix(); + hasRule = true; + } + + ByteString contains = null; + if (rules.hasContains()) { + contains = rules.getContains(); + bb.clearContains(); + hasRule = true; + } + + List inVals = + rules.getInList().isEmpty() + ? Collections.emptyList() + : new ArrayList<>(rules.getInList()); + if (!inVals.isEmpty()) { + bb.clearIn(); + hasRule = true; + } + + List notInVals = + rules.getNotInList().isEmpty() + ? Collections.emptyList() + : new ArrayList<>(rules.getNotInList()); + if (!notInVals.isEmpty()) { + bb.clearNotIn(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setBytes(bb.build()); + return new BytesRulesEvaluator( + base, + constVal, + exactLen, + minLen, + maxLen, + compiledPattern, + patternStr, + prefix, + suffix, + contains, + inVals, + notInVals, + wellKnown); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) + throws ExecutionException { + ByteString bytesVal = (ByteString) val.rawValue(); + long byteLen = bytesVal.size(); + List violations = null; + + if (constVal != null && !bytesVal.equals(constVal)) { + violations = + add( + violations, + NativeViolations.newViolation( + CONST_SITE, null, "must be " + hex(constVal), val, constVal)); + if (failFast) return done(violations); + } + + if (exactLen != null && byteLen != exactLen) { + violations = + add( + violations, + NativeViolations.newViolation( + LEN_SITE, null, "must be " + exactLen + " bytes", val, exactLen)); + if (failFast) return done(violations); + } + + if (minLen != null && byteLen < minLen) { + violations = + add( + violations, + NativeViolations.newViolation( + MIN_LEN_SITE, null, "must be at least " + minLen + " bytes", val, minLen)); + if (failFast) return done(violations); + } + + if (maxLen != null && byteLen > maxLen) { + violations = + add( + violations, + NativeViolations.newViolation( + MAX_LEN_SITE, null, "must be at most " + maxLen + " bytes", val, maxLen)); + if (failFast) return done(violations); + } + + if (pattern != null) { + if (!bytesVal.isValidUtf8()) { + // Match Go: surface this as an execution error rather than a violation. The conformance + // suite expects pattern checks to fail loudly on non-UTF-8 input. + throw new ExecutionException("must be valid UTF-8 to apply regexp"); + } + if (!pattern.matches(bytesVal.toStringUtf8())) { + violations = + add( + violations, + NativeViolations.newViolation( + PATTERN_SITE, + null, + "must match regex pattern `" + patternStr + "`", + val, + patternStr)); + if (failFast) return done(violations); + } + } + + if (prefix != null && !bytesVal.startsWith(prefix)) { + violations = + add( + violations, + NativeViolations.newViolation( + PREFIX_SITE, null, "does not have prefix " + hex(prefix), val, prefix)); + if (failFast) return done(violations); + } + + if (suffix != null && !bytesVal.endsWith(suffix)) { + violations = + add( + violations, + NativeViolations.newViolation( + SUFFIX_SITE, null, "does not have suffix " + hex(suffix), val, suffix)); + if (failFast) return done(violations); + } + + if (contains != null && !containsBytes(bytesVal, contains)) { + violations = + add( + violations, + NativeViolations.newViolation( + CONTAINS_SITE, null, "does not contain " + hex(contains), val, contains)); + if (failFast) return done(violations); + } + + if (!inVals.isEmpty() && !inVals.contains(bytesVal)) { + violations = + add( + violations, + NativeViolations.newViolation( + IN_SITE, null, "must be in list " + formatList(inVals), val, bytesVal)); + if (failFast) return done(violations); + } + + if (!notInVals.isEmpty() && notInVals.contains(bytesVal)) { + violations = + add( + violations, + NativeViolations.newViolation( + NOT_IN_SITE, + null, + "must not be in list " + formatList(notInVals), + val, + bytesVal)); + if (failFast) return done(violations); + } + + if (wellKnown != null) { + RuleViolation.Builder wkViolation = evaluateWellKnown(bytesVal, val); + if (wkViolation != null) { + violations = add(violations, wkViolation); + if (failFast) return done(violations); + } + } + + return done(violations); + } + + private RuleViolation.@Nullable Builder evaluateWellKnown(ByteString bytesVal, Value val) { + int size = bytesVal.size(); + WellKnown wk = wellKnown; + if (wk == null) { + return null; + } + if (size == 0) { + // Rule value is the bool 'true' (the rule was enabled). Site has the rule id and message + // pre-baked. + return NativeViolations.newViolation(wk.emptySite, null, null, val, true); + } + if (wk.sizeIsValid(size)) { + return null; + } + return NativeViolations.newViolation(wk.site, null, null, val, true); + } + + /** {@code ByteString} doesn't have a {@code contains} method; implement it directly. */ + private static boolean containsBytes(ByteString haystack, ByteString needle) { + int hLen = haystack.size(); + int nLen = needle.size(); + if (nLen == 0) { + return true; + } + if (nLen > hLen) { + return false; + } + outer: + for (int i = 0; i <= hLen - nLen; i++) { + for (int j = 0; j < nLen; j++) { + if (haystack.byteAt(i + j) != needle.byteAt(j)) { + continue outer; + } + } + return true; + } + return false; + } + + private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); + + /** Lowercase hex encoding to match Go's {@code fmt.Sprintf("%x", ...)} for byte slices. */ + private static String hex(ByteString bs) { + int len = bs.size(); + char[] out = new char[len * 2]; + for (int i = 0; i < len; i++) { + int b = bs.byteAt(i) & 0xff; + out[i * 2] = HEX_DIGITS[b >>> 4]; + out[i * 2 + 1] = HEX_DIGITS[b & 0xf]; + } + return new String(out); + } + + /** + * Formats a list of bytes the way CEL does — each element rendered as its raw string (UTF-8). + * Mirrors Go's {@code formatBytesList}. + */ + private static String formatList(List vals) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < vals.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(vals.get(i).toStringUtf8()); + } + sb.append("]"); + return sb.toString(); + } + + private static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + private List done(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths( + violations, base.getFieldPathElement(), base.getRulePrefixElements()); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index 8f0dfe32..f00376e9 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -77,7 +77,7 @@ private Rules() {} return null; } - // Phase 5 wires bytes, Phase 6 string, Phase 7 repeated/map. + // Phase 6 wires string, Phase 7 repeated/map. private static @Nullable Evaluator tryBuildScalarRules( FieldDescriptor fieldDescriptor, @@ -89,6 +89,8 @@ private Rules() {} return BoolRulesEvaluator.tryBuild(base, rulesBuilder); case ENUM: return EnumRulesEvaluator.tryBuild(base, rulesBuilder); + case BYTE_STRING: + return BytesRulesEvaluator.tryBuild(base, rulesBuilder); case INT: case LONG: case FLOAT: diff --git a/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java new file mode 100644 index 00000000..07c8e3f1 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java @@ -0,0 +1,128 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.Violation; +import build.buf.protovalidate.exceptions.ExecutionException; +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.BytesRules; +import com.example.noimports.validationtest.ExampleBytesConst; +import com.example.noimports.validationtest.ExampleBytesIPv4; +import com.example.noimports.validationtest.ExampleBytesPattern; +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** Validator-level tests for {@link BytesRulesEvaluator}. */ +class BytesRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setDisableNativeRules(false).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void bytesConstFailsAndCarriesExpectedShape() throws ValidationException { + // const = "\x00\x99". Empty value (default) doesn't match. + ExampleBytesConst msg = ExampleBytesConst.newBuilder().setVal(ByteString.EMPTY).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("bytes.const"); + assertThat(proto.getMessage()).isEqualTo("must be 0099"); + assertThat(proto.getRule().getElements(0).getFieldName()).isEqualTo("bytes"); + assertThat(proto.getRule().getElements(1).getFieldName()).isEqualTo("const"); + + // rule_value isn't in the proto — assert directly. + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo(ByteString.copyFrom(new byte[] {0x00, (byte) 0x99})); + FieldDescriptor expectedDesc = + BytesRules.getDescriptor().findFieldByNumber(BytesRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void bytesPatternMatchesAlphanumericOnly() throws ValidationException { + Validator v = nativeValidator(); + assertThat( + v.validate( + ExampleBytesPattern.newBuilder().setVal(ByteString.copyFromUtf8("abc")).build()) + .isSuccess()) + .isTrue(); + assertThat( + v.validate( + ExampleBytesPattern.newBuilder() + .setVal(ByteString.copyFromUtf8("abc1")) + .build()) + .isSuccess()) + .isFalse(); + } + + @Test + void bytesPatternThrowsOnInvalidUtf8() { + // Non-UTF-8 input + pattern rule → ExecutionException, matching Go's RuntimeError. + ByteString invalidUtf8 = ByteString.copyFrom(new byte[] {(byte) 0xFF, (byte) 0xFE}); + ExampleBytesPattern msg = ExampleBytesPattern.newBuilder().setVal(invalidUtf8).build(); + Validator v = nativeValidator(); + assertThatThrownBy(() -> v.validate(msg)) + .isInstanceOf(ExecutionException.class) + .hasMessageContaining("UTF-8"); + } + + @Test + void bytesIpv4WellKnownAcceptsFourBytes() throws ValidationException { + Validator v = nativeValidator(); + // 4 bytes — valid IPv4 size. + assertThat( + v.validate( + ExampleBytesIPv4.newBuilder().setVal(ByteString.copyFrom(new byte[4])).build()) + .isSuccess()) + .isTrue(); + } + + @Test + void bytesIpv4WellKnownRejectsWrongSize() throws ValidationException { + // 8 bytes — neither 0 nor 4, fails with the non-empty rule id. + ExampleBytesIPv4 msg = + ExampleBytesIPv4.newBuilder().setVal(ByteString.copyFrom(new byte[8])).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("bytes.ipv4"); + assertThat(proto.getMessage()).isEqualTo("must be a valid IPv4 address"); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + ExampleBytesConst msg = ExampleBytesConst.newBuilder().setVal(ByteString.EMPTY).build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index 1d962ebe..67d3582c 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -189,3 +189,15 @@ message ExampleEnumIn { ] }]; } + +message ExampleBytesConst { + bytes val = 1 [(buf.validate.field).bytes.const = "\x00\x99"]; +} + +message ExampleBytesPattern { + bytes val = 1 [(buf.validate.field).bytes.pattern = "^[a-z]+$"]; +} + +message ExampleBytesIPv4 { + bytes val = 1 [(buf.validate.field).bytes.ipv4 = true]; +} From 7e6dbfeea46e0f11e8bc2709a9ddac297ce16fdf Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 18:44:17 -0400 Subject: [PATCH 07/31] Add native evaluator for the standard string rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The biggest single rule type by surface area: 14 scalar rules (const/len/min_len/max_len/len_bytes/min_bytes/max_bytes/pattern/ prefix/suffix/contains/not_contains/in/not_in), 18 well-known formats (email, hostname, the ip/ipv4/ipv6 family with prefix and prefixlen variants, uri, uri_ref, address, uuid, tuuid, ulid, host_and_port), and the well_known_regex oneof case for HTTP header name/value with a strict toggle. The well-known formats reuse the existing format helpers in CustomOverload (isEmail, isHostname, isIp, isIpPrefix, isUri, isUriRef, isHostAndPort) — those are widened to public+@Internal so both the CEL custom-overload registration and the native rules path share the same parsers. No reimplementation, no risk of drift. Length rules count Unicode code points (String.codePointCount), not Java chars — surrogate-pair-encoded characters like emoji count as one. Byte-length rules count UTF-8 bytes. validateStringMatching drops 71% in time and 93% in allocation when native is enabled. validateBenchComplexSchema is now down 80% in time and 82% in allocation vs the Phase 0 baseline (numerics + bytes + string all native). --- .../buf/protovalidate/CustomOverload.java | 25 +- .../build/buf/protovalidate/rules/Rules.java | 4 +- .../rules/StringRulesEvaluator.java | 770 ++++++++++++++++++ .../rules/StringRulesEvaluatorTest.java | 138 ++++ .../proto/validationtest/validationtest.proto | 19 + 5 files changed, 946 insertions(+), 10 deletions(-) create mode 100644 src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java create mode 100644 src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java diff --git a/src/main/java/build/buf/protovalidate/CustomOverload.java b/src/main/java/build/buf/protovalidate/CustomOverload.java index dd631eed..c1f60c82 100644 --- a/src/main/java/build/buf/protovalidate/CustomOverload.java +++ b/src/main/java/build/buf/protovalidate/CustomOverload.java @@ -34,8 +34,15 @@ import java.util.Set; import java.util.concurrent.ConcurrentMap; -/** Defines custom function overloads (the implementation). */ -final class CustomOverload { +/** + * Defines custom function overloads (the implementation). + * + *

Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can reuse the + * format-validation helpers ({@link #isEmail}, {@link #isHostname}, {@link #isIp}, etc.); not part + * of the supported public API. + */ +@Internal +public final class CustomOverload { // See https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address private static final Pattern EMAIL_REGEX = @@ -417,7 +424,7 @@ private static boolean matches( *

The port is separated by a colon. It must be non-empty, with a decimal number in the range * of 0-65535, inclusive. */ - private static boolean isHostAndPort(String str, boolean portRequired) { + public static boolean isHostAndPort(String str, boolean portRequired) { if (str.isEmpty()) { return false; } @@ -503,7 +510,7 @@ private static boolean uniqueList(List list) throws CelEvaluationException { * @param addr The input string to validate as an email address. * @return {@code true} if the input string is a valid email address, {@code false} otherwise. */ - private static boolean isEmail(String addr) { + public static boolean isEmail(String addr) { return EMAIL_REGEX.matcher(addr).matches(); } @@ -521,7 +528,7 @@ private static boolean isEmail(String addr) { *

  • The name can be 253 characters at most, excluding the optional trailing dot. * */ - private static boolean isHostname(String val) { + public static boolean isHostname(String val) { if (val.length() > 253) { return false; } @@ -577,7 +584,7 @@ private static boolean isHostname(String val) { *

    Both formats are well-defined in the internet standard RFC 3986. Zone identifiers for IPv6 * addresses (for example "fe80::a%en1") are supported. */ - static boolean isIp(String addr, long ver) { + public static boolean isIp(String addr, long ver) { if (ver == 6L) { return new Ipv6(addr).address(); } else if (ver == 4L) { @@ -595,7 +602,7 @@ static boolean isIp(String addr, long ver) { *

    URI is defined in the internet standard RFC 3986. Zone Identifiers in IPv6 address literals * are supported (RFC 6874). */ - private static boolean isUri(String str) { + public static boolean isUri(String str) { return new Uri(str).uri(); } @@ -607,7 +614,7 @@ private static boolean isUri(String str) { *

    URI, URI Reference, and Relative Reference are defined in the internet standard RFC 3986. * Zone Identifiers in IPv6 address literals are supported (RFC 6874). */ - private static boolean isUriRef(String str) { + public static boolean isUriRef(String str) { return new Uri(str).uriReference(); } @@ -628,7 +635,7 @@ private static boolean isUriRef(String str) { *

    The same principle applies to IPv4 addresses. "192.168.1.0/24" designates the first 24 bits * of the 32-bit IPv4 as the network prefix. */ - private static boolean isIpPrefix(String str, long version, boolean strict) { + public static boolean isIpPrefix(String str, long version, boolean strict) { if (version == 6L) { Ipv6 ip = new Ipv6(str); return ip.addressPrefix() && (!strict || ip.isPrefixOnly()); diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index f00376e9..ca64b738 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -77,7 +77,7 @@ private Rules() {} return null; } - // Phase 6 wires string, Phase 7 repeated/map. + // Phase 7 wires repeated/map. private static @Nullable Evaluator tryBuildScalarRules( FieldDescriptor fieldDescriptor, @@ -91,6 +91,8 @@ private Rules() {} return EnumRulesEvaluator.tryBuild(base, rulesBuilder); case BYTE_STRING: return BytesRulesEvaluator.tryBuild(base, rulesBuilder); + case STRING: + return StringRulesEvaluator.tryBuild(base, rulesBuilder); case INT: case LONG: case FLOAT: diff --git a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java new file mode 100644 index 00000000..fa37172c --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java @@ -0,0 +1,770 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.CustomOverload; +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.validate.FieldRules; +import build.buf.validate.KnownRegex; +import build.buf.validate.StringRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.re2j.Pattern; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for the standard string rules: scalar rules (`const`, `len`, `min_len`, + * `max_len`, `len_bytes`, `min_bytes`, `max_bytes`, `pattern`, `prefix`, `suffix`, `contains`, + * `not_contains`, `in`, `not_in`), well-known formats (`email`, `hostname`, `ip`, `ipv4`, `ipv6`, + * `uri`, `uri_ref`, `address`, `uuid`, `tuuid`, `ulid`, `host_and_port`, the {@code _prefix} / + * {@code _with_prefixlen} variants), and the {@code well_known_regex} oneof case (HTTP header + * name/value with optional strict mode). + * + *

    Mirrors {@code nativeStringEval} in protovalidate-go's {@code native_string.go}. Format + * helpers from {@link CustomOverload} are reused so native and CEL paths share the same + * email/hostname/IP/URI parsers. + */ +final class StringRulesEvaluator implements Evaluator { + + // --- Static descriptors and rule sites --- + + private static final FieldDescriptor STRING_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.STRING_FIELD_NUMBER); + + private static final RuleSite CONST_SITE = site(StringRules.CONST_FIELD_NUMBER, "string.const"); + private static final RuleSite LEN_SITE = site(StringRules.LEN_FIELD_NUMBER, "string.len"); + private static final RuleSite MIN_LEN_SITE = + site(StringRules.MIN_LEN_FIELD_NUMBER, "string.min_len"); + private static final RuleSite MAX_LEN_SITE = + site(StringRules.MAX_LEN_FIELD_NUMBER, "string.max_len"); + private static final RuleSite LEN_BYTES_SITE = + site(StringRules.LEN_BYTES_FIELD_NUMBER, "string.len_bytes"); + private static final RuleSite MIN_BYTES_SITE = + site(StringRules.MIN_BYTES_FIELD_NUMBER, "string.min_bytes"); + private static final RuleSite MAX_BYTES_SITE = + site(StringRules.MAX_BYTES_FIELD_NUMBER, "string.max_bytes"); + private static final RuleSite PATTERN_SITE = + site(StringRules.PATTERN_FIELD_NUMBER, "string.pattern"); + private static final RuleSite PREFIX_SITE = + site(StringRules.PREFIX_FIELD_NUMBER, "string.prefix"); + private static final RuleSite SUFFIX_SITE = + site(StringRules.SUFFIX_FIELD_NUMBER, "string.suffix"); + private static final RuleSite CONTAINS_SITE = + site(StringRules.CONTAINS_FIELD_NUMBER, "string.contains"); + private static final RuleSite NOT_CONTAINS_SITE = + site(StringRules.NOT_CONTAINS_FIELD_NUMBER, "string.not_contains"); + private static final RuleSite IN_SITE = site(StringRules.IN_FIELD_NUMBER, "string.in"); + private static final RuleSite NOT_IN_SITE = + site(StringRules.NOT_IN_FIELD_NUMBER, "string.not_in"); + private static final FieldDescriptor WELL_KNOWN_REGEX_DESC = + StringRules.getDescriptor().findFieldByNumber(StringRules.WELL_KNOWN_REGEX_FIELD_NUMBER); + + private static RuleSite site(int fieldNumber, String ruleId) { + FieldDescriptor leaf = StringRules.getDescriptor().findFieldByNumber(fieldNumber); + return RuleSite.of(STRING_RULES_DESC, leaf, ruleId, null); + } + + // --- Static regexes (compile once) --- + + private static final Pattern UUID_REGEX = + Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + private static final Pattern TUUID_REGEX = Pattern.compile("^[0-9a-fA-F]{32}$"); + private static final Pattern ULID_REGEX = + Pattern.compile("^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$"); + private static final Pattern HEADER_NAME_REGEX = + Pattern.compile("^:?[0-9a-zA-Z!#$%&\\\\'*+\\-.\\^_|~`]+$"); + private static final Pattern HEADER_VALUE_REGEX = + Pattern.compile("^[^\\x00-\\x08\\x0A-\\x1F\\x7F]*$"); + private static final Pattern LOOSE_REGEX = Pattern.compile("^[^\\x00\\x0A\\x0D]+$"); + + // --- Well-known string formats --- + + /** Each constant carries the rule id, message, empty-value variant, and validation. */ + @SuppressWarnings("ImmutableEnumChecker") // RuleSite is logically immutable; not annotated. + enum WellKnownFormat { + EMAIL(StringRules.EMAIL_FIELD_NUMBER, "email", "must be a valid email address") { + @Override + boolean validate(String s) { + return CustomOverload.isEmail(s); + } + }, + HOSTNAME(StringRules.HOSTNAME_FIELD_NUMBER, "hostname", "must be a valid hostname") { + @Override + boolean validate(String s) { + return CustomOverload.isHostname(s); + } + }, + IP(StringRules.IP_FIELD_NUMBER, "ip", "must be a valid IP address") { + @Override + boolean validate(String s) { + return CustomOverload.isIp(s, 0); + } + }, + IPV4(StringRules.IPV4_FIELD_NUMBER, "ipv4", "must be a valid IPv4 address") { + @Override + boolean validate(String s) { + return CustomOverload.isIp(s, 4); + } + }, + IPV6(StringRules.IPV6_FIELD_NUMBER, "ipv6", "must be a valid IPv6 address") { + @Override + boolean validate(String s) { + return CustomOverload.isIp(s, 6); + } + }, + URI(StringRules.URI_FIELD_NUMBER, "uri", "must be a valid URI") { + @Override + boolean validate(String s) { + return CustomOverload.isUri(s); + } + }, + URI_REF(StringRules.URI_REF_FIELD_NUMBER, "uri_ref", "must be a valid URI Reference") { + @Override + boolean validate(String s) { + return CustomOverload.isUriRef(s); + } + + @Override + boolean checksEmpty() { + return false; + } + }, + ADDRESS( + StringRules.ADDRESS_FIELD_NUMBER, "address", "must be a valid hostname, or ip address") { + @Override + boolean validate(String s) { + return CustomOverload.isHostname(s) || CustomOverload.isIp(s, 0); + } + }, + UUID(StringRules.UUID_FIELD_NUMBER, "uuid", "must be a valid UUID") { + @Override + boolean validate(String s) { + return UUID_REGEX.matches(s); + } + }, + TUUID(StringRules.TUUID_FIELD_NUMBER, "tuuid", "must be a valid trimmed UUID") { + @Override + boolean validate(String s) { + return TUUID_REGEX.matches(s); + } + }, + IP_WITH_PREFIXLEN( + StringRules.IP_WITH_PREFIXLEN_FIELD_NUMBER, + "ip_with_prefixlen", + "must be a valid IP prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 0, false); + } + }, + IPV4_WITH_PREFIXLEN( + StringRules.IPV4_WITH_PREFIXLEN_FIELD_NUMBER, + "ipv4_with_prefixlen", + "must be a valid IPv4 address with prefix length") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 4, false); + } + }, + IPV6_WITH_PREFIXLEN( + StringRules.IPV6_WITH_PREFIXLEN_FIELD_NUMBER, + "ipv6_with_prefixlen", + "must be a valid IPv6 address with prefix length") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 6, false); + } + }, + IP_PREFIX(StringRules.IP_PREFIX_FIELD_NUMBER, "ip_prefix", "must be a valid IP prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 0, true); + } + }, + IPV4_PREFIX( + StringRules.IPV4_PREFIX_FIELD_NUMBER, "ipv4_prefix", "must be a valid IPv4 prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 4, true); + } + }, + IPV6_PREFIX( + StringRules.IPV6_PREFIX_FIELD_NUMBER, "ipv6_prefix", "must be a valid IPv6 prefix") { + @Override + boolean validate(String s) { + return CustomOverload.isIpPrefix(s, 6, true); + } + }, + HOST_AND_PORT( + StringRules.HOST_AND_PORT_FIELD_NUMBER, + "host_and_port", + "must be a valid host (hostname or IP address) and port pair") { + @Override + boolean validate(String s) { + return CustomOverload.isHostAndPort(s, true); + } + }, + ULID(StringRules.ULID_FIELD_NUMBER, "ulid", "must be a valid ULID") { + @Override + boolean validate(String s) { + return ULID_REGEX.matches(s); + } + }; + + final FieldDescriptor field; + final String ruleSuffix; + final RuleSite site; + final RuleSite emptySite; + + WellKnownFormat(int fieldNumber, String ruleSuffix, String message) { + FieldDescriptor leaf = StringRules.getDescriptor().findFieldByNumber(fieldNumber); + this.field = leaf; + this.ruleSuffix = ruleSuffix; + this.site = RuleSite.of(STRING_RULES_DESC, leaf, "string." + ruleSuffix, message); + // The empty-variant message reads "value is empty, which is not a valid "; build + // it from the format's display name (everything after "must be a valid " in the message, + // minus the trailing comma form for ADDRESS). + String displayName = message.replace("must be a valid ", ""); + this.emptySite = + RuleSite.of( + STRING_RULES_DESC, + leaf, + "string." + ruleSuffix + "_empty", + "value is empty, which is not a valid " + displayName); + } + + /** Whether this format reports an empty-value violation distinctly from the format failure. */ + boolean checksEmpty() { + return true; + } + + abstract boolean validate(String s); + } + + // --- Fields --- + + private final RuleBase base; + private final @Nullable String constVal; + private final @Nullable Long exactLen; + private final @Nullable Long minLen; + private final @Nullable Long maxLen; + private final @Nullable Long exactBytes; + private final @Nullable Long minBytes; + private final @Nullable Long maxBytes; + private final @Nullable Pattern pattern; + private final @Nullable String patternStr; + private final @Nullable String prefix; + private final @Nullable String suffix; + private final @Nullable String contains; + private final @Nullable String notContains; + private final List inVals; + private final List notInVals; + private final @Nullable WellKnownFormat wellKnown; + private final KnownRegex knownRegex; + private final boolean knownRegexStrict; + + private StringRulesEvaluator( + RuleBase base, + @Nullable String constVal, + @Nullable Long exactLen, + @Nullable Long minLen, + @Nullable Long maxLen, + @Nullable Long exactBytes, + @Nullable Long minBytes, + @Nullable Long maxBytes, + @Nullable Pattern pattern, + @Nullable String patternStr, + @Nullable String prefix, + @Nullable String suffix, + @Nullable String contains, + @Nullable String notContains, + List inVals, + List notInVals, + @Nullable WellKnownFormat wellKnown, + KnownRegex knownRegex, + boolean knownRegexStrict) { + this.base = base; + this.constVal = constVal; + this.exactLen = exactLen; + this.minLen = minLen; + this.maxLen = maxLen; + this.exactBytes = exactBytes; + this.minBytes = minBytes; + this.maxBytes = maxBytes; + this.pattern = pattern; + this.patternStr = patternStr; + this.prefix = prefix; + this.suffix = suffix; + this.contains = contains; + this.notContains = notContains; + this.inVals = inVals; + this.notInVals = notInVals; + this.wellKnown = wellKnown; + this.knownRegex = knownRegex; + this.knownRegexStrict = knownRegexStrict; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasString()) { + return null; + } + StringRules rules = rulesBuilder.getString(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + StringRules.Builder sb = rules.toBuilder(); + boolean hasRule = false; + + // Well-known oneof: at most one of the format fields, OR well_known_regex, can be set. + WellKnownFormat wellKnown = null; + KnownRegex knownRegex = KnownRegex.KNOWN_REGEX_UNSPECIFIED; + boolean knownRegexStrict = false; + for (WellKnownFormat fmt : WellKnownFormat.values()) { + if (rules.hasField(fmt.field)) { + boolean enabled = (Boolean) rules.getField(fmt.field); + if (enabled) { + wellKnown = fmt; + sb.clearField(fmt.field); + hasRule = true; + } + break; + } + } + if (wellKnown == null && rules.hasWellKnownRegex()) { + knownRegex = rules.getWellKnownRegex(); + // strict defaults to true when not explicitly set. + knownRegexStrict = !rules.hasStrict() || rules.getStrict(); + if (knownRegex != KnownRegex.KNOWN_REGEX_UNSPECIFIED) { + sb.clearWellKnownRegex(); + if (rules.hasStrict()) { + sb.clearStrict(); + } + hasRule = true; + } + } + + String constVal = null; + if (rules.hasConst()) { + constVal = rules.getConst(); + sb.clearConst(); + hasRule = true; + } + Long exactLen = null; + if (rules.hasLen()) { + exactLen = rules.getLen(); + sb.clearLen(); + hasRule = true; + } + Long minLen = null; + if (rules.hasMinLen()) { + minLen = rules.getMinLen(); + sb.clearMinLen(); + hasRule = true; + } + Long maxLen = null; + if (rules.hasMaxLen()) { + maxLen = rules.getMaxLen(); + sb.clearMaxLen(); + hasRule = true; + } + Long exactBytes = null; + if (rules.hasLenBytes()) { + exactBytes = rules.getLenBytes(); + sb.clearLenBytes(); + hasRule = true; + } + Long minBytes = null; + if (rules.hasMinBytes()) { + minBytes = rules.getMinBytes(); + sb.clearMinBytes(); + hasRule = true; + } + Long maxBytes = null; + if (rules.hasMaxBytes()) { + maxBytes = rules.getMaxBytes(); + sb.clearMaxBytes(); + hasRule = true; + } + + Pattern compiledPattern = null; + String patternStr = null; + if (rules.hasPattern()) { + patternStr = rules.getPattern(); + try { + compiledPattern = Pattern.compile(patternStr); + } catch (com.google.re2j.PatternSyntaxException e) { + return null; // bail to CEL — same compilation error + } + sb.clearPattern(); + hasRule = true; + } + + String prefix = null; + if (rules.hasPrefix()) { + prefix = rules.getPrefix(); + sb.clearPrefix(); + hasRule = true; + } + String suffix = null; + if (rules.hasSuffix()) { + suffix = rules.getSuffix(); + sb.clearSuffix(); + hasRule = true; + } + String contains = null; + if (rules.hasContains()) { + contains = rules.getContains(); + sb.clearContains(); + hasRule = true; + } + String notContains = null; + if (rules.hasNotContains()) { + notContains = rules.getNotContains(); + sb.clearNotContains(); + hasRule = true; + } + + List inVals = + rules.getInList().isEmpty() + ? Collections.emptyList() + : new ArrayList<>(rules.getInList()); + if (!inVals.isEmpty()) { + sb.clearIn(); + hasRule = true; + } + + List notInVals = + rules.getNotInList().isEmpty() + ? Collections.emptyList() + : new ArrayList<>(rules.getNotInList()); + if (!notInVals.isEmpty()) { + sb.clearNotIn(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setString(sb.build()); + return new StringRulesEvaluator( + base, + constVal, + exactLen, + minLen, + maxLen, + exactBytes, + minBytes, + maxBytes, + compiledPattern, + patternStr, + prefix, + suffix, + contains, + notContains, + inVals, + notInVals, + wellKnown, + knownRegex, + knownRegexStrict); + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + String strVal = (String) val.rawValue(); + List violations = null; + + if (exactLen != null || minLen != null || maxLen != null) { + long runeCount = strVal.codePointCount(0, strVal.length()); + violations = applyLength(violations, val, runeCount, failFast); + if (failFast && violations != null) { + return done(violations); + } + } + + if (exactBytes != null || minBytes != null || maxBytes != null) { + long byteCount = strVal.getBytes(StandardCharsets.UTF_8).length; + violations = applyByteLength(violations, val, byteCount, failFast); + if (failFast && violations != null) { + return done(violations); + } + } + + if (constVal != null && !strVal.equals(constVal)) { + violations = + add( + violations, + NativeViolations.newViolation( + CONST_SITE, null, "must equal `" + constVal + "`", val, constVal)); + if (failFast) return done(violations); + } + + if (pattern != null && !pattern.matches(strVal)) { + violations = + add( + violations, + NativeViolations.newViolation( + PATTERN_SITE, + null, + "does not match regex pattern `" + patternStr + "`", + val, + patternStr)); + if (failFast) return done(violations); + } + + if (prefix != null && !strVal.startsWith(prefix)) { + violations = + add( + violations, + NativeViolations.newViolation( + PREFIX_SITE, null, "does not have prefix `" + prefix + "`", val, prefix)); + if (failFast) return done(violations); + } + + if (suffix != null && !strVal.endsWith(suffix)) { + violations = + add( + violations, + NativeViolations.newViolation( + SUFFIX_SITE, null, "does not have suffix `" + suffix + "`", val, suffix)); + if (failFast) return done(violations); + } + + if (contains != null && !strVal.contains(contains)) { + violations = + add( + violations, + NativeViolations.newViolation( + CONTAINS_SITE, + null, + "does not contain substring `" + contains + "`", + val, + contains)); + if (failFast) return done(violations); + } + + if (notContains != null && strVal.contains(notContains)) { + violations = + add( + violations, + NativeViolations.newViolation( + NOT_CONTAINS_SITE, + null, + "value contains substring `" + notContains + "`", + val, + notContains)); + if (failFast) return done(violations); + } + + if (!inVals.isEmpty() && !inVals.contains(strVal)) { + violations = + add( + violations, + NativeViolations.newViolation( + IN_SITE, null, "must be in list " + formatList(inVals), val, strVal)); + if (failFast) return done(violations); + } + + if (!notInVals.isEmpty() && notInVals.contains(strVal)) { + violations = + add( + violations, + NativeViolations.newViolation( + NOT_IN_SITE, null, "must not be in list " + formatList(notInVals), val, strVal)); + if (failFast) return done(violations); + } + + if (wellKnown != null) { + RuleViolation.Builder wkv = checkWellKnown(strVal, val); + if (wkv != null) { + violations = add(violations, wkv); + if (failFast) return done(violations); + } + } else if (knownRegex != KnownRegex.KNOWN_REGEX_UNSPECIFIED) { + RuleViolation.Builder krv = checkKnownRegex(strVal, val); + if (krv != null) { + violations = add(violations, krv); + if (failFast) return done(violations); + } + } + + return done(violations); + } + + // --- Length checks --- + + private @Nullable List applyLength( + @Nullable List violations, + Value val, + long runeCount, + boolean failFast) { + if (exactLen != null && runeCount != exactLen) { + violations = + add( + violations, + NativeViolations.newViolation( + LEN_SITE, null, "must be " + exactLen + " characters", val, exactLen)); + if (failFast) return violations; + } + if (minLen != null && runeCount < minLen) { + violations = + add( + violations, + NativeViolations.newViolation( + MIN_LEN_SITE, null, "must be at least " + minLen + " characters", val, minLen)); + if (failFast) return violations; + } + if (maxLen != null && runeCount > maxLen) { + violations = + add( + violations, + NativeViolations.newViolation( + MAX_LEN_SITE, null, "must be at most " + maxLen + " characters", val, maxLen)); + if (failFast) return violations; + } + return violations; + } + + private @Nullable List applyByteLength( + @Nullable List violations, + Value val, + long byteCount, + boolean failFast) { + if (exactBytes != null && byteCount != exactBytes) { + violations = + add( + violations, + NativeViolations.newViolation( + LEN_BYTES_SITE, null, "must be " + exactBytes + " bytes", val, exactBytes)); + if (failFast) return violations; + } + if (minBytes != null && byteCount < minBytes) { + violations = + add( + violations, + NativeViolations.newViolation( + MIN_BYTES_SITE, null, "must be at least " + minBytes + " bytes", val, minBytes)); + if (failFast) return violations; + } + if (maxBytes != null && byteCount > maxBytes) { + violations = + add( + violations, + NativeViolations.newViolation( + MAX_BYTES_SITE, null, "must be at most " + maxBytes + " bytes", val, maxBytes)); + if (failFast) return violations; + } + return violations; + } + + // --- Well-known format check --- + + private RuleViolation.@Nullable Builder checkWellKnown(String strVal, Value val) { + WellKnownFormat fmt = wellKnown; + if (fmt == null) { + return null; + } + if (fmt.checksEmpty() && strVal.isEmpty()) { + return NativeViolations.newViolation(fmt.emptySite, null, null, val, true); + } + if (fmt.validate(strVal)) { + return null; + } + return NativeViolations.newViolation(fmt.site, null, null, val, true); + } + + private RuleViolation.@Nullable Builder checkKnownRegex(String strVal, Value val) { + Pattern matcher; + String ruleId; + String message; + switch (knownRegex) { + case KNOWN_REGEX_HTTP_HEADER_NAME: + if (strVal.isEmpty()) { + return NativeViolations.newViolation( + RuleSite.of( + STRING_RULES_DESC, + WELL_KNOWN_REGEX_DESC, + "string.well_known_regex.header_name_empty", + "value is empty, which is not a valid HTTP header name"), + null, + null, + val, + knownRegex.getNumber()); + } + matcher = HEADER_NAME_REGEX; + ruleId = "string.well_known_regex.header_name"; + message = "must be a valid HTTP header name"; + break; + case KNOWN_REGEX_HTTP_HEADER_VALUE: + matcher = HEADER_VALUE_REGEX; + ruleId = "string.well_known_regex.header_value"; + message = "must be a valid HTTP header value"; + break; + default: + return null; + } + if (!knownRegexStrict) { + matcher = LOOSE_REGEX; + } + if (!matcher.matches(strVal)) { + RuleSite site = RuleSite.of(STRING_RULES_DESC, WELL_KNOWN_REGEX_DESC, ruleId, message); + return NativeViolations.newViolation(site, null, null, val, knownRegex.getNumber()); + } + return null; + } + + // --- Helpers --- + + private static String formatList(List vals) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < vals.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(vals.get(i)); + } + sb.append("]"); + return sb.toString(); + } + + private static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + private List done(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths( + violations, base.getFieldPathElement(), base.getRulePrefixElements()); + } +} diff --git a/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java new file mode 100644 index 00000000..7354d818 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java @@ -0,0 +1,138 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.Violation; +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.StringRules; +import com.example.noimports.validationtest.ExampleStringConst; +import com.example.noimports.validationtest.ExampleStringEmail; +import com.example.noimports.validationtest.ExampleStringHostAndPort; +import com.example.noimports.validationtest.ExampleStringMinMaxLen; +import com.google.protobuf.Descriptors.FieldDescriptor; +import org.junit.jupiter.api.Test; + +/** Validator-level tests for {@link StringRulesEvaluator}. */ +class StringRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setDisableNativeRules(false).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void stringConstFailsAndCarriesExpectedShape() throws ValidationException { + ExampleStringConst msg = ExampleStringConst.newBuilder().setVal("nope").build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + Violation v = result.getViolations().get(0); + + build.buf.validate.Violation proto = v.toProto(); + assertThat(proto.getRuleId()).isEqualTo("string.const"); + assertThat(proto.getMessage()).isEqualTo("must equal `abcd`"); + + Violation.FieldValue ruleValue = v.getRuleValue(); + assertThat(ruleValue).isNotNull(); + assertThat(ruleValue.getValue()).isEqualTo("abcd"); + FieldDescriptor expectedDesc = + StringRules.getDescriptor().findFieldByNumber(StringRules.CONST_FIELD_NUMBER); + assertThat(ruleValue.getDescriptor()).isEqualTo(expectedDesc); + } + + @Test + void emailWellKnownAcceptsValidAndRejectsInvalid() throws ValidationException { + Validator v = nativeValidator(); + assertThat( + v.validate(ExampleStringEmail.newBuilder().setVal("alice@example.com").build()) + .isSuccess()) + .isTrue(); + assertThat( + v.validate(ExampleStringEmail.newBuilder().setVal("not-an-email").build()).isSuccess()) + .isFalse(); + } + + @Test + void emailWellKnownReportsEmptyVariant() throws ValidationException { + // Empty string fires the *_empty variant rule id. + ExampleStringEmail msg = ExampleStringEmail.newBuilder().setVal("").build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("string.email_empty"); + assertThat(proto.getMessage()).contains("value is empty"); + } + + @Test + void minMaxLenAppliesCharacterCounts() throws ValidationException { + Validator v = nativeValidator(); + // min_len=2, max_len=5 + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("ab").build()).isSuccess()) + .isTrue(); + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("abcde").build()).isSuccess()) + .isTrue(); + // 1 character — too short. + ValidationResult tooShort = v.validate(ExampleStringMinMaxLen.newBuilder().setVal("a").build()); + assertThat(tooShort.getViolations()).hasSize(1); + assertThat(tooShort.getViolations().get(0).toProto().getRuleId()).isEqualTo("string.min_len"); + // 6 characters — too long. + ValidationResult tooLong = + v.validate(ExampleStringMinMaxLen.newBuilder().setVal("abcdef").build()); + assertThat(tooLong.getViolations()).hasSize(1); + assertThat(tooLong.getViolations().get(0).toProto().getRuleId()).isEqualTo("string.max_len"); + } + + @Test + void minLenCountsCodePointsNotJavaChars() throws ValidationException { + // Each emoji is a single code point but two Java chars (surrogate pair). min_len=2 should + // count code points, not chars. + Validator v = nativeValidator(); + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("😀😀").build()).isSuccess()) + .isTrue(); + // One emoji = 1 code point, fails min_len=2. + assertThat(v.validate(ExampleStringMinMaxLen.newBuilder().setVal("😀").build()).isSuccess()) + .isFalse(); + } + + @Test + void hostAndPortAcceptsValidAndRejectsInvalid() throws ValidationException { + Validator v = nativeValidator(); + assertThat( + v.validate(ExampleStringHostAndPort.newBuilder().setVal("example.com:8080").build()) + .isSuccess()) + .isTrue(); + assertThat( + v.validate(ExampleStringHostAndPort.newBuilder().setVal("not-a-host-and-port").build()) + .isSuccess()) + .isFalse(); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + ExampleStringConst msg = ExampleStringConst.newBuilder().setVal("nope").build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index 67d3582c..74735d17 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -201,3 +201,22 @@ message ExampleBytesPattern { message ExampleBytesIPv4 { bytes val = 1 [(buf.validate.field).bytes.ipv4 = true]; } + +message ExampleStringConst { + string val = 1 [(buf.validate.field).string.const = "abcd"]; +} + +message ExampleStringEmail { + string val = 1 [(buf.validate.field).string.email = true]; +} + +message ExampleStringMinMaxLen { + string val = 1 [(buf.validate.field).string = { + min_len: 2 + max_len: 5 + }]; +} + +message ExampleStringHostAndPort { + string val = 1 [(buf.validate.field).string.host_and_port = true]; +} From a4e0568e2923e65f0143860b872b02fa0aeb2b8e Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 18:58:27 -0400 Subject: [PATCH 08/31] Add native evaluators for repeated and map list/map-level rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers repeated min_items/max_items/unique and map min_pairs/max_pairs. Element-level / key-and-value rules continue to flow through the existing ListEvaluator/MapEvaluator path; this phase only handles the top-level list/map rules that don't recurse into elements. The unique check uses a linear O(n²) scan for lists of 16 or fewer items (no allocation) and falls back to a HashSet for larger lists, mirroring Go's threshold. Element kinds with reliable Object.equals (scalars, strings, bools, bytes, enums) are supported; message/group element kinds bail to CEL since their equality is reference-only. This is the last rule type in scope for the native-rules port. With all six rule phases native, BenchComplexSchema drops 90.5% in time (55,536 → 5,260 ns/op) and 92.4% in allocation (133,120 → 10,100 B/op) vs the pre-port baseline. The remaining ~10% is enum defined_only, oneof required, field-level required, and wrapper- typed scalars — all intentionally out of scope (the first three are already non-CEL evaluators; wrapper support is deferred). --- .../rules/MapRulesEvaluator.java | 152 +++++++++++ .../rules/RepeatedRulesEvaluator.java | 243 ++++++++++++++++++ .../build/buf/protovalidate/rules/Rules.java | 8 +- .../RepeatedAndMapRulesEvaluatorTest.java | 125 +++++++++ .../proto/validationtest/validationtest.proto | 18 ++ 5 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java create mode 100644 src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java create mode 100644 src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java diff --git a/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java new file mode 100644 index 00000000..80bb8102 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java @@ -0,0 +1,152 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.validate.FieldRules; +import build.buf.validate.MapRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for map-level rules: {@code min_pairs} and {@code max_pairs}. Key/value rules + * continue to flow through {@link build.buf.protovalidate.MapEvaluator} and the inner key/value + * {@link build.buf.protovalidate.ValueEvaluator}s. Mirrors {@code nativeMapEval} in + * protovalidate-go's {@code native_map.go}. + */ +final class MapRulesEvaluator implements Evaluator { + private static final FieldDescriptor MAP_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.MAP_FIELD_NUMBER); + + private static final RuleSite MIN_PAIRS_SITE = + RuleSite.of( + MAP_RULES_DESC, + MapRules.getDescriptor().findFieldByNumber(MapRules.MIN_PAIRS_FIELD_NUMBER), + "map.min_pairs", + null); + private static final RuleSite MAX_PAIRS_SITE = + RuleSite.of( + MAP_RULES_DESC, + MapRules.getDescriptor().findFieldByNumber(MapRules.MAX_PAIRS_FIELD_NUMBER), + "map.max_pairs", + null); + + private final RuleBase base; + private final @Nullable Long minPairs; + private final @Nullable Long maxPairs; + + private MapRulesEvaluator(RuleBase base, @Nullable Long minPairs, @Nullable Long maxPairs) { + this.base = base; + this.minPairs = minPairs; + this.maxPairs = maxPairs; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasMap()) { + return null; + } + MapRules rules = rulesBuilder.getMap(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + MapRules.Builder mb = rules.toBuilder(); + boolean hasRule = false; + + Long minPairs = null; + if (rules.hasMinPairs()) { + minPairs = rules.getMinPairs(); + mb.clearMinPairs(); + hasRule = true; + } + + Long maxPairs = null; + if (rules.hasMaxPairs()) { + maxPairs = rules.getMaxPairs(); + mb.clearMaxPairs(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setMap(mb.build()); + return new MapRulesEvaluator(base, minPairs, maxPairs); + } + + @Override + public boolean tautology() { + return minPairs == null && maxPairs == null; + } + + @Override + public List evaluate(Value val, boolean failFast) { + // Java protobuf returns map fields as a List of synthetic key/value entry messages; the size + // is the pair count. + List entries = (List) val.rawValue(); + long size = entries.size(); + List violations = null; + + if (minPairs != null && size < minPairs) { + violations = + add( + violations, + NativeViolations.newViolation( + MIN_PAIRS_SITE, + null, + "map must be at least " + minPairs + " entries", + val, + minPairs)); + if (failFast) return done(violations); + } + + if (maxPairs != null && size > maxPairs) { + violations = + add( + violations, + NativeViolations.newViolation( + MAX_PAIRS_SITE, + null, + "map must be at most " + maxPairs + " entries", + val, + maxPairs)); + if (failFast) return done(violations); + } + + return done(violations); + } + + private static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + private List done(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths( + violations, base.getFieldPathElement(), base.getRulePrefixElements()); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java new file mode 100644 index 00000000..03601439 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java @@ -0,0 +1,243 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.validate.FieldRules; +import build.buf.validate.RepeatedRules; +import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * Native evaluator for repeated list-level rules: {@code min_items}, {@code max_items}, {@code + * unique}. Element-level rules continue to flow through {@code ListEvaluator} and the inner {@link + * build.buf.protovalidate.ValueEvaluator}. + * + *

    The {@code unique} rule is only supported when the element kind has well-defined value + * equality (scalars, strings, bytes, bools, enums). Message/group element kinds fall back to CEL. + * Mirrors {@code nativeRepeatedEval} in protovalidate-go's {@code native_repeated.go}. + */ +final class RepeatedRulesEvaluator implements Evaluator { + private static final FieldDescriptor REPEATED_RULES_DESC = + FieldRules.getDescriptor().findFieldByNumber(FieldRules.REPEATED_FIELD_NUMBER); + + private static final RuleSite MIN_ITEMS_SITE = + RuleSite.of( + REPEATED_RULES_DESC, + RepeatedRules.getDescriptor().findFieldByNumber(RepeatedRules.MIN_ITEMS_FIELD_NUMBER), + "repeated.min_items", + null); + private static final RuleSite MAX_ITEMS_SITE = + RuleSite.of( + REPEATED_RULES_DESC, + RepeatedRules.getDescriptor().findFieldByNumber(RepeatedRules.MAX_ITEMS_FIELD_NUMBER), + "repeated.max_items", + null); + private static final RuleSite UNIQUE_SITE = + RuleSite.of( + REPEATED_RULES_DESC, + RepeatedRules.getDescriptor().findFieldByNumber(RepeatedRules.UNIQUE_FIELD_NUMBER), + "repeated.unique", + "repeated value must contain unique items"); + + /** Below this list size, the linear-scan unique check beats {@link HashSet} (no allocation). */ + private static final int UNIQUE_LINEAR_THRESHOLD = 16; + + private final RuleBase base; + private final @Nullable Long minItems; + private final @Nullable Long maxItems; + private final boolean unique; + + private RepeatedRulesEvaluator( + RuleBase base, @Nullable Long minItems, @Nullable Long maxItems, boolean unique) { + this.base = base; + this.minItems = minItems; + this.maxItems = maxItems; + this.unique = unique; + } + + static @Nullable Evaluator tryBuild(RuleBase base, FieldRules.Builder rulesBuilder) { + if (!rulesBuilder.hasRepeated()) { + return null; + } + RepeatedRules rules = rulesBuilder.getRepeated(); + if (!rules.getUnknownFields().isEmpty()) { + return null; + } + + RepeatedRules.Builder rb = rules.toBuilder(); + boolean hasRule = false; + + Long minItems = null; + if (rules.hasMinItems()) { + minItems = rules.getMinItems(); + rb.clearMinItems(); + hasRule = true; + } + + Long maxItems = null; + if (rules.hasMaxItems()) { + maxItems = rules.getMaxItems(); + rb.clearMaxItems(); + hasRule = true; + } + + boolean unique = false; + if (rules.getUnique()) { + // Element kind must support reliable Object.equals — scalars, strings, bools, bytes, enums + // all do (ByteString and EnumValueDescriptor have correct equals/hashCode). Messages don't, + // so fall through to CEL. + FieldDescriptor descriptor = base.getDescriptor(); + if (descriptor == null || !isUniqueSupported(descriptor.getType())) { + return null; + } + unique = true; + rb.clearUnique(); + hasRule = true; + } + + if (!hasRule) { + return null; + } + rulesBuilder.setRepeated(rb.build()); + return new RepeatedRulesEvaluator(base, minItems, maxItems, unique); + } + + private static boolean isUniqueSupported(FieldDescriptor.Type type) { + switch (type) { + case INT32: + case SINT32: + case SFIXED32: + case INT64: + case SINT64: + case SFIXED64: + case UINT32: + case FIXED32: + case UINT64: + case FIXED64: + case FLOAT: + case DOUBLE: + case BOOL: + case STRING: + case BYTES: + case ENUM: + return true; + case MESSAGE: + case GROUP: + default: + return false; + } + } + + @Override + public boolean tautology() { + return false; + } + + @Override + public List evaluate(Value val, boolean failFast) { + List list = (List) val.rawValue(); + long size = list.size(); + List violations = null; + + if (minItems != null && size < minItems) { + violations = + add( + violations, + NativeViolations.newViolation( + MIN_ITEMS_SITE, + null, + "must contain at least " + minItems + " item(s)", + val, + minItems)); + if (failFast) return done(violations); + } + + if (maxItems != null && size > maxItems) { + violations = + add( + violations, + NativeViolations.newViolation( + MAX_ITEMS_SITE, + null, + "must contain no more than " + maxItems + " item(s)", + val, + maxItems)); + if (failFast) return done(violations); + } + + if (unique && !isUnique(list)) { + violations = + add(violations, NativeViolations.newViolation(UNIQUE_SITE, null, null, val, true)); + if (failFast) return done(violations); + } + + return done(violations); + } + + /** + * Returns true iff every element in {@code list} is distinct. Below {@link + * #UNIQUE_LINEAR_THRESHOLD} elements uses an O(n²) scan with no auxiliary allocation; above that + * uses a {@link HashSet}. + */ + private static boolean isUnique(List list) { + int size = list.size(); + if (size <= 1) { + return true; + } + if (size <= UNIQUE_LINEAR_THRESHOLD) { + for (int i = 1; i < size; i++) { + Object current = list.get(i); + for (int j = 0; j < i; j++) { + if (current.equals(list.get(j))) { + return false; + } + } + } + return true; + } + Set seen = new HashSet<>(size); + for (Object element : list) { + if (!seen.add(element)) { + return false; + } + } + return true; + } + + private static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + private List done(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths( + violations, base.getFieldPathElement(), base.getRulePrefixElements()); + } +} diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index ca64b738..38c2e7a0 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -77,8 +77,6 @@ private Rules() {} return null; } - // Phase 7 wires repeated/map. - private static @Nullable Evaluator tryBuildScalarRules( FieldDescriptor fieldDescriptor, FieldRules.Builder rulesBuilder, @@ -168,15 +166,13 @@ private static int rulesFieldNumberFor(NumericTypeConfig config) { throw new IllegalArgumentException("unknown numeric config"); } - @SuppressWarnings("unused") private static @Nullable Evaluator tryBuildRepeatedRules( FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { - return null; + return RepeatedRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); } - @SuppressWarnings("unused") private static @Nullable Evaluator tryBuildMapRules( FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { - return null; + return MapRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); } } diff --git a/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java new file mode 100644 index 00000000..16ab2a72 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java @@ -0,0 +1,125 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.ExampleMapMinMax; +import com.example.noimports.validationtest.ExampleRepeatedMinMax; +import com.example.noimports.validationtest.ExampleRepeatedUnique; +import org.junit.jupiter.api.Test; + +/** Validator-level tests for {@link RepeatedRulesEvaluator} and {@link MapRulesEvaluator}. */ +class RepeatedAndMapRulesEvaluatorTest { + + private static Validator nativeValidator() { + Config config = Config.newBuilder().setDisableNativeRules(false).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void repeatedMinItemsViolation() throws ValidationException { + // 1 item, min_items=2. + ExampleRepeatedMinMax msg = ExampleRepeatedMinMax.newBuilder().addVal(1).build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("repeated.min_items"); + assertThat(proto.getMessage()).isEqualTo("must contain at least 2 item(s)"); + } + + @Test + void repeatedMaxItemsViolation() throws ValidationException { + // 6 items, max_items=5. + ExampleRepeatedMinMax msg = + ExampleRepeatedMinMax.newBuilder() + .addVal(1) + .addVal(2) + .addVal(3) + .addVal(4) + .addVal(5) + .addVal(6) + .build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("repeated.max_items"); + assertThat(proto.getMessage()).isEqualTo("must contain no more than 5 item(s)"); + } + + @Test + void repeatedUniqueValid() throws ValidationException { + ExampleRepeatedUnique msg = + ExampleRepeatedUnique.newBuilder().addVal("a").addVal("b").addVal("c").build(); + assertThat(nativeValidator().validate(msg).isSuccess()).isTrue(); + } + + @Test + void repeatedUniqueViolation() throws ValidationException { + ExampleRepeatedUnique msg = + ExampleRepeatedUnique.newBuilder().addVal("a").addVal("b").addVal("a").build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("repeated.unique"); + assertThat(proto.getMessage()).isEqualTo("repeated value must contain unique items"); + } + + @Test + void mapMinPairsViolation() throws ValidationException { + // Empty map, min_pairs=1. + ExampleMapMinMax msg = ExampleMapMinMax.newBuilder().build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("map.min_pairs"); + assertThat(proto.getMessage()).isEqualTo("map must be at least 1 entries"); + } + + @Test + void mapMaxPairsViolation() throws ValidationException { + // 4 entries, max_pairs=3. + ExampleMapMinMax msg = + ExampleMapMinMax.newBuilder() + .putVal("a", "1") + .putVal("b", "2") + .putVal("c", "3") + .putVal("d", "4") + .build(); + ValidationResult result = nativeValidator().validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("map.max_pairs"); + assertThat(proto.getMessage()).isEqualTo("map must be at most 3 entries"); + } + + @Test + void nativeAndCelProduceEqualViolationProto() throws ValidationException { + // Repeated unique violation in both modes. + ExampleRepeatedUnique msg = ExampleRepeatedUnique.newBuilder().addVal("a").addVal("a").build(); + Validator nativeV = nativeValidator(); + Validator celV = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .build(); + assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) + .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index 74735d17..9cf613a9 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -220,3 +220,21 @@ message ExampleStringMinMaxLen { message ExampleStringHostAndPort { string val = 1 [(buf.validate.field).string.host_and_port = true]; } + +message ExampleRepeatedMinMax { + repeated int32 val = 1 [(buf.validate.field).repeated = { + min_items: 2 + max_items: 5 + }]; +} + +message ExampleRepeatedUnique { + repeated string val = 1 [(buf.validate.field).repeated.unique = true]; +} + +message ExampleMapMinMax { + map val = 1 [(buf.validate.field).map = { + min_pairs: 1 + max_pairs: 3 + }]; +} From aa2840ad2ce60a6bf5d15ec0a8320c5b692fc74d Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 19:06:24 -0400 Subject: [PATCH 09/31] Add native-rules parity test and document the opt-in flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity test runs a representative slice of conformance fixtures — one per rule type, plus the kitchen-sink composite — through both evaluation modes and asserts byte-equal Violation.toProto() output. This is stricter than the conformance suite, which by default omits the message text from comparison; the parity test catches any between-mode drift on rule_id, field path, rule path, AND message. Documents the Config.setDisableNativeRules opt-in in README, listing what's covered and noting wrapper-typed scalars as a follow-up. --- README.md | 17 +++ .../protovalidate/NativeRulesParityTest.java | 118 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java diff --git a/README.md b/README.md index 48a6796a..f26bec9f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,23 @@ Highlights for Java developers include: * A comprehensive RPC quickstart for [Java and gRPC][grpc-java] * A [migration guide for protoc-gen-validate][migration-guide] users +## Native rule evaluators (opt-in) + +The standard rules can be evaluated either through CEL or through native Java code. Native evaluation is functionally identical (the conformance suite passes in both modes) but skips CEL compilation and runtime overhead for the rules it covers — a single `validate()` call on a complex message can run an order of magnitude faster and allocate ~10× less. + +Native rules are **opt-in** while the implementation matures. Enable them by configuring the validator: + +```java +Config config = Config.newBuilder().setDisableNativeRules(false).build(); +Validator validator = ValidatorFactory.newBuilder().withConfig(config).build(); +``` + +Native evaluators currently cover bool, all 12 numeric kinds (signed and unsigned int32/int64, float, double, etc.), enum (`const`/`in`/`not_in`; the existing `defined_only` path is unchanged), bytes, string (including all well-known formats), and repeated/map list-level rules (`min_items`/`max_items`/`unique`, `min_pairs`/`max_pairs`). + +Forward compatibility is preserved by a clone-and-clear contract: when protovalidate adds a new rule that this codebase hasn't yet implemented natively, the rule remains on the residual `FieldRules` and CEL enforces it. Native evaluation is an optimization, never a replacement. + +Wrapper-typed scalar fields (`google.protobuf.Int32Value`, `BoolValue`, etc.) currently fall through to CEL; native wrapper unwrap is a planned follow-up. + ## Additional languages and repositories Protovalidate isn't just for Java! You might be interested in sibling repositories for other languages: diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java new file mode 100644 index 00000000..7c8c5b6e --- /dev/null +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -0,0 +1,118 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.conformance.cases.AnEnum; +import build.buf.validate.conformance.cases.BoolConstTrue; +import build.buf.validate.conformance.cases.BytesContains; +import build.buf.validate.conformance.cases.BytesIn; +import build.buf.validate.conformance.cases.ComplexTestMsg; +import build.buf.validate.conformance.cases.EnumDefined; +import build.buf.validate.conformance.cases.Fixed32LT; +import build.buf.validate.conformance.cases.Int32In; +import build.buf.validate.conformance.cases.KitchenSinkMessage; +import build.buf.validate.conformance.cases.RepeatedEnumIn; +import build.buf.validate.conformance.cases.RepeatedExact; +import build.buf.validate.conformance.cases.RepeatedUnique; +import build.buf.validate.conformance.cases.SFixed64In; +import build.buf.validate.conformance.cases.StringContains; +import build.buf.validate.conformance.cases.StringLen; +import build.buf.validate.conformance.cases.StringPrefix; +import com.google.protobuf.ByteString; +import com.google.protobuf.Message; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * Parity test: runs a representative slice of conformance fixtures through both modes + * ({@code disableNativeRules=true} and {@code false}) and asserts the resulting + * {@code Violation} protos are byte-equal. The conformance suite proves each mode is correct in + * isolation; this test proves they don't drift from each other on the same input. + * + *

    Conformance message text is excluded from the suite's default comparison (only + * {@code rule_id}, {@code field}, {@code rule}, {@code for_key} are compared unless + * {@code --strict_message} is set), but {@code toProto()} captures all of those plus the + * message text. Asserting full {@code toProto()} equality here is therefore stricter than + * conformance. + */ +class NativeRulesParityTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(false).build()) + .build(); + private final Validator celValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .build(); + + @Test + void parityAcrossRuleTypes() throws ValidationException { + // Each fixture exercises a different rule type. Order: + // bool, enum, bytes, numeric (signed + unsigned), string (scalar + format), repeated, map. + List fixtures = + Arrays.asList( + // Bool — fails const=true. + BoolConstTrue.newBuilder().build(), + // Enum defined_only — fails (2147483647 not in defined values). + EnumDefined.newBuilder().setValValue(2147483647).build(), + // Bytes contains — pass case. + BytesContains.newBuilder().setVal(ByteString.copyFromUtf8("candy bars")).build(), + // Bytes in — pass case (empty matches none of the in list, but the field is + // implicit-presence so the rule is skipped on empty value). + BytesIn.newBuilder().setVal(ByteString.copyFromUtf8("bar")).build(), + // Fixed32 (unsigned) lt — fails (val=5, lt=5). + Fixed32LT.newBuilder().setVal(5).build(), + // Int32 in — fails (4 not in list). + Int32In.newBuilder().setVal(4).build(), + // SFixed64 in — fails (5 not in list). + SFixed64In.newBuilder().setVal(5).build(), + // String prefix — pass case. + StringPrefix.newBuilder().setVal("foo").build(), + // String contains — pass case. + StringContains.newBuilder().setVal("foobar").build(), + // String length with code points — emoji counts as 1 each. + StringLen.newBuilder().setVal("😅😄👾").build(), + // Repeated exact — fails (2 items, exact=3). + RepeatedExact.newBuilder().addAllVal(Arrays.asList(1, 2)).build(), + // Repeated unique — fails (duplicate "foo"). + RepeatedUnique.newBuilder() + .addAllVal(Arrays.asList("foo", "bar", "foo", "baz")) + .build(), + // Repeated enum in — fails. + RepeatedEnumIn.newBuilder().addVal(AnEnum.AN_ENUM_X).build(), + // KitchenSinkMessage with empty inner ComplexTestMsg — many violations. + KitchenSinkMessage.newBuilder().setVal(ComplexTestMsg.newBuilder().build()).build()); + + for (Message msg : fixtures) { + ValidationResult nativeResult = nativeValidator.validate(msg); + ValidationResult celResult = celValidator.validate(msg); + assertThat(toProtoList(nativeResult)) + .as("toProto() parity failed for %s", msg.getDescriptorForType().getFullName()) + .isEqualTo(toProtoList(celResult)); + } + } + + private static List toProtoList(ValidationResult result) { + return result.getViolations().stream() + .map(Violation::toProto) + .collect(Collectors.toList()); + } +} From a2bbd0068a3e5c8bd1b1a6cf07a22db3a83d4b49 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 19:13:48 -0400 Subject: [PATCH 10/31] Add native wrapper unwrap for google.protobuf.{Bool,Int32,...}Value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WrappedValueEvaluator pulls the inner 'value' field off a wrapper Message at evaluation time and delegates to the same scalar native evaluator (BoolRulesEvaluator, NumericRulesEvaluator, etc.) that would have been used for a direct field of the wrapped type. The RuleBase still uses the OUTER wrapper field's descriptor so violation field paths point at the user's wrapper-typed field, matching the field path produced by the CEL path. Removes the Phase 2 wrapper-bypass that returned null from the dispatcher when the outer descriptor was a Message. The CEL path was correct because CEL's runtime auto-unwraps protobuf wrappers; native evaluators expect the underlying scalar (e.g. Long for Int64Value.value), which is what the unwrap produces. ObjectValue is widened to public+@Internal so the rules package can construct one when delegating into the inner scalar evaluator. validateWrapperTesting drops 94.8% in time (16,866 → 880 ns/op) and 96.6% in allocation (39,956 → 1,376 B/op). Conformance passes 2872/2872 in both modes; the parity test is extended to include WrapperDouble for explicit coverage. --- README.md | 4 +- .../protovalidate/NativeRulesParityTest.java | 5 ++ .../build/buf/protovalidate/ObjectValue.java | 13 +++- .../build/buf/protovalidate/rules/Rules.java | 16 +++-- .../rules/WrappedValueEvaluator.java | 65 +++++++++++++++++++ 5 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java diff --git a/README.md b/README.md index f26bec9f..b777a138 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,10 @@ Config config = Config.newBuilder().setDisableNativeRules(false).build(); Validator validator = ValidatorFactory.newBuilder().withConfig(config).build(); ``` -Native evaluators currently cover bool, all 12 numeric kinds (signed and unsigned int32/int64, float, double, etc.), enum (`const`/`in`/`not_in`; the existing `defined_only` path is unchanged), bytes, string (including all well-known formats), and repeated/map list-level rules (`min_items`/`max_items`/`unique`, `min_pairs`/`max_pairs`). +Native evaluators currently cover bool, all 12 numeric kinds (signed and unsigned int32/int64, float, double, etc.), enum (`const`/`in`/`not_in`; the existing `defined_only` path is unchanged), bytes, string (including all well-known formats), repeated/map list-level rules (`min_items`/`max_items`/`unique`, `min_pairs`/`max_pairs`), and the `google.protobuf.{Bool,Int32,Int64,UInt32,UInt64,Float,Double,String,Bytes}Value` wrapper types — rules on wrapper-typed fields run through the same native evaluators after unwrapping. Forward compatibility is preserved by a clone-and-clear contract: when protovalidate adds a new rule that this codebase hasn't yet implemented natively, the rule remains on the residual `FieldRules` and CEL enforces it. Native evaluation is an optimization, never a replacement. -Wrapper-typed scalar fields (`google.protobuf.Int32Value`, `BoolValue`, etc.) currently fall through to CEL; native wrapper unwrap is a planned follow-up. - ## Additional languages and repositories Protovalidate isn't just for Java! You might be interested in sibling repositories for other languages: diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index 7c8c5b6e..9586dd99 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -33,7 +33,9 @@ import build.buf.validate.conformance.cases.StringContains; import build.buf.validate.conformance.cases.StringLen; import build.buf.validate.conformance.cases.StringPrefix; +import build.buf.validate.conformance.cases.WrapperDouble; import com.google.protobuf.ByteString; +import com.google.protobuf.DoubleValue; import com.google.protobuf.Message; import java.util.Arrays; import java.util.List; @@ -98,6 +100,9 @@ void parityAcrossRuleTypes() throws ValidationException { .build(), // Repeated enum in — fails. RepeatedEnumIn.newBuilder().addVal(AnEnum.AN_ENUM_X).build(), + // Wrapper-typed double (google.protobuf.DoubleValue) — exercises the native + // wrapper-unwrap path. Empty wrapper = value 0.0, fails the rule. + WrapperDouble.newBuilder().setVal(DoubleValue.newBuilder().build()).build(), // KitchenSinkMessage with empty inner ComplexTestMsg — many violations. KitchenSinkMessage.newBuilder().setVal(ComplexTestMsg.newBuilder().build()).build()); diff --git a/src/main/java/build/buf/protovalidate/ObjectValue.java b/src/main/java/build/buf/protovalidate/ObjectValue.java index 9a53574a..a4101c17 100644 --- a/src/main/java/build/buf/protovalidate/ObjectValue.java +++ b/src/main/java/build/buf/protovalidate/ObjectValue.java @@ -24,8 +24,15 @@ import java.util.Map; import org.jspecify.annotations.Nullable; -/** The {@link Value} type that contains a field descriptor and its value. */ -final class ObjectValue implements Value { +/** + * The {@link Value} type that contains a field descriptor and its value. + * + *

    Public so that {@code WrappedValueEvaluator} in {@code build.buf.protovalidate.rules} can + * construct one when unwrapping a {@code google.protobuf.*Value} field; not part of the supported + * public API. + */ +@Internal +public final class ObjectValue implements Value { /** * {@link com.google.protobuf.Descriptors.FieldDescriptor} is the field descriptor for the value. @@ -41,7 +48,7 @@ final class ObjectValue implements Value { * @param fieldDescriptor The field descriptor for the value. * @param value The value associated with the field descriptor. */ - ObjectValue(Descriptors.FieldDescriptor fieldDescriptor, Object value) { + public ObjectValue(Descriptors.FieldDescriptor fieldDescriptor, Object value) { this.fieldDescriptor = fieldDescriptor; this.value = value; } diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index 38c2e7a0..3c72698d 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -62,17 +62,19 @@ private Rules() {} return tryBuildRepeatedRules(rulesBuilder, valueEvaluator); } if (!fieldDescriptor.isMapField() && !fieldDescriptor.isRepeated()) { - // Wrapper fields (google.protobuf.{Bool,Int32,...}Value) are recursed into via - // processWrapperRules with the inner "value" field as fieldDescriptor; the value passed - // to evaluate() is still the wrapper Message. CEL transparently unwraps these, but native - // evaluators don't get that for free. Defer wrapper support to a follow-up; CEL handles - // wrappers correctly today. + Evaluator scalar = tryBuildScalarRules(fieldDescriptor, rulesBuilder, valueEvaluator); + if (scalar == null) { + return null; + } + // When processWrapperRules recurses with the inner "value" field, the ValueEvaluator's + // descriptor is still the OUTER wrapper field. Detect that and wrap the scalar evaluator + // so it unwraps the wrapper Message at evaluation time before delegating. FieldDescriptor outerDescriptor = valueEvaluator.getDescriptor(); if (outerDescriptor != null && outerDescriptor.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { - return null; + return new WrappedValueEvaluator(fieldDescriptor, scalar); } - return tryBuildScalarRules(fieldDescriptor, rulesBuilder, valueEvaluator); + return scalar; } return null; } diff --git a/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java b/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java new file mode 100644 index 00000000..d58d2bfa --- /dev/null +++ b/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java @@ -0,0 +1,65 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import build.buf.protovalidate.Evaluator; +import build.buf.protovalidate.ObjectValue; +import build.buf.protovalidate.RuleViolation; +import build.buf.protovalidate.Value; +import build.buf.protovalidate.exceptions.ExecutionException; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Message; +import java.util.List; + +/** + * Adapter that lets a scalar-rule evaluator run against a {@code google.protobuf.*Value} wrapper + * field. At evaluation time it pulls the inner {@code value} field off the wrapper {@link Message} + * and delegates to the wrapped scalar evaluator. + * + *

    CEL's runtime auto-unwraps wrappers to their inner primitive type, so the CEL path doesn't + * need this adapter. Native evaluators expect the underlying scalar (e.g. {@code Long} for {@code + * Int64Value.value}); without unwrapping they'd see the wrapper {@link Message} and misbehave. + * Mirrors {@code wrappedValueEval} in protovalidate-go's {@code builder.go}. + * + *

    The wrapped evaluator's {@link RuleBase} is constructed against the OUTER wrapper field's + * {@code ValueEvaluator}, so violation field paths point at the user's wrapper-typed field rather + * than the synthetic inner {@code value}. + */ +final class WrappedValueEvaluator implements Evaluator { + private final FieldDescriptor innerField; + private final Evaluator inner; + + WrappedValueEvaluator(FieldDescriptor innerField, Evaluator inner) { + this.innerField = innerField; + this.inner = inner; + } + + @Override + public boolean tautology() { + return inner.tautology(); + } + + @Override + public List evaluate(Value val, boolean failFast) + throws ExecutionException { + Message message = val.messageValue(); + if (message == null) { + // proto3 message-typed field absent — no value to validate. + return RuleViolation.NO_VIOLATIONS; + } + Object innerValue = message.getField(innerField); + return inner.evaluate(new ObjectValue(innerField, innerValue), failFast); + } +} From d1416263d485ef90fc3726ec40f28959a0ebfa4a Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 19:23:47 -0400 Subject: [PATCH 11/31] Invert flag polarity from disableNativeRules to enableNativeRules Reads more naturally: setting enableNativeRules to true turns native rule evaluation on, false turns it off, default is false. Same effective behavior (CEL-only by default; opt in to native), but the API name is direct rather than double-negative. Renames Config.setDisableNativeRules -> Config.setEnableNativeRules, Config.isNativeRulesDisabled -> Config.isNativeRulesEnabled, the EvaluatorBuilder field, the conformance env var DISABLE_NATIVE_RULES -> ENABLE_NATIVE_RULES, the JMH @Param across both benchmark classes, and all test/doc references. Bench @Param order flips to {"false","true"} so the CEL-only run still comes first; the regression-gate run is unchanged. --- README.md | 2 +- .../benchmarks/EvaluatorBuildBenchmark.java | 6 +-- .../benchmarks/ValidationBenchmark.java | 10 ++--- .../buf/protovalidate/conformance/Main.java | 6 +-- .../protovalidate/NativeRulesParityTest.java | 6 +-- .../java/build/buf/protovalidate/Config.java | 42 +++++++++---------- .../buf/protovalidate/EvaluatorBuilder.java | 21 +++++----- .../rules/BoolRulesEvaluatorTest.java | 4 +- .../rules/BytesRulesEvaluatorTest.java | 4 +- .../rules/EnumRulesEvaluatorTest.java | 4 +- .../rules/NumericRulesEvaluatorTest.java | 4 +- .../RepeatedAndMapRulesEvaluatorTest.java | 4 +- .../rules/StringRulesEvaluatorTest.java | 4 +- 13 files changed, 59 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index b777a138..5b9591a2 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The standard rules can be evaluated either through CEL or through native Java co Native rules are **opt-in** while the implementation matures. Enable them by configuring the validator: ```java -Config config = Config.newBuilder().setDisableNativeRules(false).build(); +Config config = Config.newBuilder().setEnableNativeRules(true).build(); Validator validator = ValidatorFactory.newBuilder().withConfig(config).build(); ``` diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java index 01ac6df0..8f22a121 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java @@ -42,8 +42,8 @@ @State(Scope.Benchmark) public class EvaluatorBuildBenchmark { - @Param({"true", "false"}) - public boolean disableNativeRules; + @Param({"false", "true"}) + public boolean enableNativeRules; private Config config; private Message benchComplexSchema; @@ -51,7 +51,7 @@ public class EvaluatorBuildBenchmark { @Setup public void setup() { - config = Config.newBuilder().setDisableNativeRules(disableNativeRules).build(); + config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); benchComplexSchema = BenchComplexSchema.getDefaultInstance(); benchGT = BenchGT.getDefaultInstance(); } diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index 6dc7c6c9..a4a87946 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -56,8 +56,8 @@ * measures its improvements. The original {@code validate*} methods exercise past PR fixes * (tautology skip, AST cache, etc.) and remain as regression guards. * - *

    The {@code disableNativeRules} parameter A/Bs the native-rules flag: {@code "true"} matches - * the Phase 0 CEL-only baseline; {@code "false"} measures native evaluation. Each subsequent phase + *

    The {@code enableNativeRules} parameter A/Bs the native-rules flag: {@code "false"} matches + * the Phase 0 CEL-only baseline; {@code "true"} measures native evaluation. Each subsequent phase * reports the gap between the two modes for its covered benchmarks. */ @BenchmarkMode(Mode.AverageTime) @@ -65,8 +65,8 @@ @State(Scope.Benchmark) public class ValidationBenchmark { - @Param({"true", "false"}) - public boolean disableNativeRules; + @Param({"false", "true"}) + public boolean enableNativeRules; private Validator validator; @@ -95,7 +95,7 @@ public class ValidationBenchmark { @Setup public void setup() throws ValidationException { - Config config = Config.newBuilder().setDisableNativeRules(disableNativeRules).build(); + Config config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); validator = ValidatorFactory.newBuilder().withConfig(config).build(); simple = SimpleStringMessage.newBuilder().setEmail("alice@example.com").build(); diff --git a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java index 0d432a2a..38c83f0b 100644 --- a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java +++ b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java @@ -61,14 +61,14 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) { TypeRegistry typeRegistry = FileDescriptorUtil.createTypeRegistry(fileDescriptorMap.values()); ExtensionRegistry extensionRegistry = FileDescriptorUtil.createExtensionRegistry(fileDescriptorMap.values()); - // DISABLE_NATIVE_RULES env var lets the conformance runner exercise both rule-evaluation + // ENABLE_NATIVE_RULES env var lets the conformance runner exercise both rule-evaluation // modes without code changes. Defaults to whatever Config's default is so a plain // `gradlew :conformance:test` matches user-facing behavior. - String envFlag = System.getenv("DISABLE_NATIVE_RULES"); + String envFlag = System.getenv("ENABLE_NATIVE_RULES"); Config.Builder cfgBuilder = Config.newBuilder().setTypeRegistry(typeRegistry).setExtensionRegistry(extensionRegistry); if (envFlag != null) { - cfgBuilder.setDisableNativeRules(Boolean.parseBoolean(envFlag)); + cfgBuilder.setEnableNativeRules(Boolean.parseBoolean(envFlag)); } Config cfg = cfgBuilder.build(); Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build(); diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index 9586dd99..313d6432 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -44,7 +44,7 @@ /** * Parity test: runs a representative slice of conformance fixtures through both modes - * ({@code disableNativeRules=true} and {@code false}) and asserts the resulting + * ({@code enableNativeRules=true} and {@code false}) and asserts the resulting * {@code Violation} protos are byte-equal. The conformance suite proves each mode is correct in * isolation; this test proves they don't drift from each other on the same input. * @@ -58,11 +58,11 @@ class NativeRulesParityTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(false).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) .build(); private final Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); @Test diff --git a/src/main/java/build/buf/protovalidate/Config.java b/src/main/java/build/buf/protovalidate/Config.java index b4dab3d3..8fff81b8 100644 --- a/src/main/java/build/buf/protovalidate/Config.java +++ b/src/main/java/build/buf/protovalidate/Config.java @@ -27,19 +27,19 @@ public final class Config { private final TypeRegistry typeRegistry; private final ExtensionRegistry extensionRegistry; private final boolean allowUnknownFields; - private final boolean disableNativeRules; + private final boolean enableNativeRules; private Config( boolean failFast, TypeRegistry typeRegistry, ExtensionRegistry extensionRegistry, boolean allowUnknownFields, - boolean disableNativeRules) { + boolean enableNativeRules) { this.failFast = failFast; this.typeRegistry = typeRegistry; this.extensionRegistry = extensionRegistry; this.allowUnknownFields = allowUnknownFields; - this.disableNativeRules = disableNativeRules; + this.enableNativeRules = enableNativeRules; } /** @@ -88,17 +88,17 @@ public boolean isAllowingUnknownFields() { } /** - * Checks whether native (non-CEL) rule evaluators are disabled. + * Checks whether native (non-CEL) rule evaluators are enabled. * - *

    When false, standard rules with a native Java implementation bypass CEL evaluation. When - * true, all rules go through CEL. Defaults to true while the native-rules port is in flight; - * applications opt in by calling {@link Builder#setDisableNativeRules(boolean) - * setDisableNativeRules(false)}. + *

    When true, standard rules with a native Java implementation bypass CEL evaluation. When + * false, all rules go through CEL. Defaults to false while the native-rules implementation + * matures; applications opt in by calling {@link Builder#setEnableNativeRules(boolean) + * setEnableNativeRules(true)}. * - * @return true if native rules are disabled (CEL handles everything). + * @return true if native rules are enabled. */ - public boolean isNativeRulesDisabled() { - return disableNativeRules; + public boolean isNativeRulesEnabled() { + return enableNativeRules; } /** Builder for configuration. Provides a forward compatible API for users. */ @@ -107,7 +107,7 @@ public static final class Builder { private TypeRegistry typeRegistry = DEFAULT_TYPE_REGISTRY; private ExtensionRegistry extensionRegistry = DEFAULT_EXTENSION_REGISTRY; private boolean allowUnknownFields; - private boolean disableNativeRules = true; + private boolean enableNativeRules; private Builder() {} @@ -176,17 +176,17 @@ public Builder setAllowUnknownFields(boolean allowUnknownFields) { } /** - * Set whether native (non-CEL) rule evaluators are disabled. Defaults to true while the - * native-rules port is in flight; pass false to opt in to native evaluation of standard rules. - * Forward-compatible: any rule not yet implemented natively continues to be enforced via CEL - * regardless of this setting. + * Set whether native (non-CEL) rule evaluators are enabled. Defaults to false while the + * native-rules implementation matures; pass true to opt in to native evaluation of standard + * rules. Forward-compatible: any rule not yet implemented natively continues to be enforced via + * CEL regardless of this setting. * - * @param disableNativeRules true to disable native rules and route everything through CEL; - * false to use native evaluators where available. + * @param enableNativeRules true to use native evaluators where available; false to route + * everything through CEL. * @return this builder */ - public Builder setDisableNativeRules(boolean disableNativeRules) { - this.disableNativeRules = disableNativeRules; + public Builder setEnableNativeRules(boolean enableNativeRules) { + this.enableNativeRules = enableNativeRules; return this; } @@ -197,7 +197,7 @@ public Builder setDisableNativeRules(boolean disableNativeRules) { */ public Config build() { return new Config( - failFast, typeRegistry, extensionRegistry, allowUnknownFields, disableNativeRules); + failFast, typeRegistry, extensionRegistry, allowUnknownFields, enableNativeRules); } } } diff --git a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java index aaf33c86..bfe7b743 100644 --- a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java +++ b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java @@ -61,7 +61,7 @@ final class EvaluatorBuilder { private final Cel cel; private final boolean disableLazy; - private final boolean disableNativeRules; + private final boolean enableNativeRules; private final RuleCache rules; /** @@ -73,7 +73,7 @@ final class EvaluatorBuilder { EvaluatorBuilder(Cel cel, Config config) { this.cel = cel; this.disableLazy = false; - this.disableNativeRules = config.isNativeRulesDisabled(); + this.enableNativeRules = config.isNativeRulesEnabled(); this.rules = new RuleCache(cel, config); } @@ -88,7 +88,7 @@ final class EvaluatorBuilder { Objects.requireNonNull(descriptors, "descriptors must not be null"); this.cel = cel; this.disableLazy = disableLazy; - this.disableNativeRules = config.isNativeRulesDisabled(); + this.enableNativeRules = config.isNativeRulesEnabled(); this.rules = new RuleCache(cel, config); for (Descriptor descriptor : descriptors) { @@ -130,7 +130,7 @@ private Evaluator build(Descriptor desc) throws CompilationException { } // Rebuild cache with this descriptor (and any of its dependencies). Map updatedCache = - new DescriptorCacheBuilder(cel, rules, disableNativeRules, evaluatorCache).build(desc); + new DescriptorCacheBuilder(cel, rules, enableNativeRules, evaluatorCache).build(desc); evaluatorCache = updatedCache; eval = updatedCache.get(desc); if (eval == null) { @@ -145,17 +145,17 @@ private static class DescriptorCacheBuilder { private final RuleResolver resolver = new RuleResolver(); private final Cel cel; private final RuleCache ruleCache; - private final boolean disableNativeRules; + private final boolean enableNativeRules; private final HashMap cache; private DescriptorCacheBuilder( Cel cel, RuleCache ruleCache, - boolean disableNativeRules, + boolean enableNativeRules, Map previousCache) { this.cel = Objects.requireNonNull(cel, "cel"); this.ruleCache = Objects.requireNonNull(ruleCache, "ruleCache"); - this.disableNativeRules = disableNativeRules; + this.enableNativeRules = enableNativeRules; this.cache = new HashMap<>(previousCache); } @@ -472,9 +472,10 @@ private void processStandardRules( } } - // Try native rule evaluators (Phase 1: dispatcher always returns null). Any rule covered - // natively is cleared on the residual builder so CEL only compiles what's left. - if (!disableNativeRules) { + // Try native rule evaluators when opted in. Any rule covered natively is cleared on the + // residual builder so CEL only compiles what's left; rules without a native implementation + // remain on the residual and CEL handles them. + if (enableNativeRules) { FieldRules.Builder rulesBuilder = fieldRules.toBuilder(); Evaluator nativeEval = Rules.tryBuild(fieldDescriptor, rulesBuilder, valueEvaluatorEval); if (nativeEval != null) { diff --git a/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java index 5abd83a0..ea405995 100644 --- a/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java @@ -36,7 +36,7 @@ class BoolRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setDisableNativeRules(false).build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -93,7 +93,7 @@ void nativeAndCelProducePartiallyEqualViolations() throws ValidationException { ValidationResult nativeResult = nativeValidator().validate(msg); Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); ValidationResult celResult = celValidator.validate(msg); diff --git a/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java index 07c8e3f1..578337d1 100644 --- a/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java @@ -36,7 +36,7 @@ class BytesRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setDisableNativeRules(false).build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -120,7 +120,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java index e41d8e05..44ccb0c8 100644 --- a/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java @@ -33,7 +33,7 @@ class EnumRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setDisableNativeRules(false).build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -84,7 +84,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java index 484efb35..c340211e 100644 --- a/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java @@ -49,7 +49,7 @@ class NumericRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setDisableNativeRules(false).build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -160,7 +160,7 @@ void nativeAndCelProduceEqualViolationProtos() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) diff --git a/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java index 16ab2a72..394bda49 100644 --- a/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java @@ -30,7 +30,7 @@ class RepeatedAndMapRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setDisableNativeRules(false).build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -117,7 +117,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java index 7354d818..5bb81feb 100644 --- a/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java @@ -34,7 +34,7 @@ class StringRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setDisableNativeRules(false).build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -130,7 +130,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); From d865f59a25ba8298fcfb64af66e16cd19fc1e857 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 30 Apr 2026 19:27:53 -0400 Subject: [PATCH 12/31] Run conformance suite in both modes on every PR Adds make conformance-native that runs the conformance CLI with ENABLE_NATIVE_RULES=true, and updates the Conformance workflow to run both CEL-only (default) and native-enabled passes. Catches any between-mode drift on every PR rather than only when someone runs the parity test locally. JMH-based regression detection in CI was considered but rejected: at sub-microsecond benchmark scales JMH variance on shared CI runners exceeds the 5% gate that's meaningful, so a per-PR perf gate would be either too loose to catch real regressions or too flaky to trust. The conformance dual-mode pass is the strict correctness gate; JMH stays a developer-local A/B via the existing benchmarks/RESULTS.md workflow. --- .github/workflows/conformance.yaml | 4 +++- Makefile | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index 3e5fd58d..96592693 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -37,5 +37,7 @@ jobs: token: ${{ secrets.BUF_TOKEN }} - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - - name: Test conformance + - name: Test conformance (CEL-only — default mode) run: make conformance + - name: Test conformance (native rules enabled) + run: make conformance-native diff --git a/Makefile b/Makefile index 9849c9a9..07f6e005 100644 --- a/Makefile +++ b/Makefile @@ -29,9 +29,13 @@ clean: ## Delete intermediate build artifacts $(GRADLE) clean .PHONY: conformance -conformance: ## Execute conformance tests. +conformance: ## Execute conformance tests with default (CEL-only) rule evaluation. $(GRADLE) conformance:conformance +.PHONY: conformance-native +conformance-native: ## Execute conformance tests with native rule evaluators enabled. + ENABLE_NATIVE_RULES=true $(GRADLE) conformance:conformance + .PHONY: help help: ## Describe useful make targets @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-15s %s\n", $$1, $$2}' From 3a7e5d4f85cd75b33d3bde6502faf5c16e99525d Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 1 May 2026 16:18:49 -0400 Subject: [PATCH 13/31] =?UTF-8?q?Fix=20float=20comparator=20and=20formatte?= =?UTF-8?q?r=20for=20=C2=B10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NumericTypeConfig.floatCompare/doubleCompare now treat +0.0 and -0.0 as equal so a rule like float.const = -0.0 against val = +0.0 no longer fires a spurious violation, and the formatters preserve the sign in violation messages by checking the bit pattern before the whole-number short-circuit. RepeatedRulesEvaluator.isUnique drops the linear-scan fast path for short lists; the HashSet path is uniform across sizes. Adds FloatBugConfirmationTest plus the validationtest.proto fixtures it exercises (ExampleFloat/DoubleConstNegZero, ExampleFloat/DoubleRepeatedUnique, FloatDoubleNaNNegZero) to lock in native-vs-CEL parity on -0 const messages and document that NaN/±0 unique semantics agree across both paths (both deviate from CEL's IEEE-754 spec via CustomOverload.uniqueList). --- .../rules/NumericTypeConfig.java | 48 +++-- .../rules/RepeatedRulesEvaluator.java | 18 +- .../rules/FloatBugConfirmationTest.java | 186 ++++++++++++++++++ .../proto/validationtest/validationtest.proto | 35 ++++ 4 files changed, 256 insertions(+), 31 deletions(-) create mode 100644 src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java diff --git a/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java b/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java index c56d05aa..3d141493 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java +++ b/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java @@ -221,7 +221,7 @@ private static FieldDescriptor frField(int number) { NumericDescriptors.build( frField(FieldRules.FLOAT_FIELD_NUMBER), FloatRules.getDescriptor(), "float", true), Float.class, - Float::compare, + NumericTypeConfig::floatCompare, NumericTypeConfig::floatFormatter, true); @@ -231,29 +231,49 @@ private static FieldDescriptor frField(int number) { NumericDescriptors.build( frField(FieldRules.DOUBLE_FIELD_NUMBER), DoubleRules.getDescriptor(), "double", true), Double.class, - Double::compare, - NumericTypeConfig::floatFormatter, + NumericTypeConfig::doubleCompare, + NumericTypeConfig::doubleFormatter, true); - public static String floatFormatter(Object obj) { - if (obj instanceof Float) { + public static int floatCompare(Float f1, Float f2) { + // this makes sure 0 == -0 + if((f1 == 0.0)&&(f2 == 0.0)) { + return 0; + } + return f1.compareTo(f2); + } + + public static int doubleCompare(Double f1, Double f2) { + // this makes sure 0 == -0 + if(f1 == 0.0 && f2 == 0.0) { + return 0; + } + return f1.compareTo(f2); + } + + public static String floatFormatter(Float f) { + // if the float is -0, print it as -0 + if (Float.floatToIntBits(f) == 1<<31) { + return "-0"; + } // if the float is a whole number, don't print the decimal - Float f = (Float) obj; float f2 = f.intValue(); if (f2 == f) { - return String.valueOf(f.intValue()); + return String.valueOf(f.intValue()); } return String.valueOf(f); - } - if (obj instanceof Double) { - // if the float is a whole number, don't print the decimal - Double d = (Double) obj; + } + + public static String doubleFormatter(Double d) { + // if the double is -0, print it as -0 + if (Double.doubleToLongBits(d) == 1L<<63) { + return "-0"; + } + // if the double is a whole number, don't print the decimal double d2 = d.intValue(); if (d2 == d) { - return String.valueOf(d.intValue()); + return String.valueOf(d.intValue()); } return String.valueOf(d); - } - return String.valueOf(obj); } } diff --git a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java index 03601439..ce02c55f 100644 --- a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java @@ -59,9 +59,6 @@ final class RepeatedRulesEvaluator implements Evaluator { "repeated.unique", "repeated value must contain unique items"); - /** Below this list size, the linear-scan unique check beats {@link HashSet} (no allocation). */ - private static final int UNIQUE_LINEAR_THRESHOLD = 16; - private final RuleBase base; private final @Nullable Long minItems; private final @Nullable Long maxItems; @@ -195,26 +192,13 @@ public List evaluate(Value val, boolean failFast) { } /** - * Returns true iff every element in {@code list} is distinct. Below {@link - * #UNIQUE_LINEAR_THRESHOLD} elements uses an O(n²) scan with no auxiliary allocation; above that - * uses a {@link HashSet}. + * Returns true iff every element in {@code list} is distinct. Uses a {@link HashSet} to test for uniqueness. */ private static boolean isUnique(List list) { int size = list.size(); if (size <= 1) { return true; } - if (size <= UNIQUE_LINEAR_THRESHOLD) { - for (int i = 1; i < size; i++) { - Object current = list.get(i); - for (int j = 0; j < i; j++) { - if (current.equals(list.get(j))) { - return false; - } - } - } - return true; - } Set seen = new HashSet<>(size); for (Object element : list) { if (!seen.add(element)) { diff --git a/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java b/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java new file mode 100644 index 00000000..de48c96e --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java @@ -0,0 +1,186 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.imports.validationtest.FloatDoubleNaNNegZero; +import com.example.noimports.validationtest.ExampleDoubleConstNegZero; +import com.example.noimports.validationtest.ExampleDoubleRepeatedUnique; +import com.example.noimports.validationtest.ExampleFloatConstNegZero; +import com.example.noimports.validationtest.ExampleFloatRepeatedUnique; +import com.google.protobuf.Message; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * Comparative tests for the floating-point findings in NATIVE_RULES_REVIEW.md (B1, B2). Each test + * runs the same input through the native and CEL evaluation paths and asserts on the resulting + * {@code Violation} protos. + * + *

    Outcomes after running: + * + *

      + *
    • B1 — fixed. {@code floatFormatter}/{@code doubleFormatter} now check the sign bit + * on entry and return {@code "-0"} for negative zero, so {@code float.const = -0.0} produces + * the same violation message in both modes. The tests below lock in that parity. + *
    • B2 — reclassified. Original review claimed native diverges from CEL on + * {@code repeated.unique} for {@code NaN}/{@code -0.0}. Investigation showed CEL's + * {@code unique()} is registered by protovalidate-java itself (see + * {@code CustomOverload.uniqueList}) and uses {@code Object.equals} on a {@link + * java.util.HashSet} — the same defect as {@code RepeatedRulesEvaluator.isUnique}. So both + * paths agree (both deviate from the CEL spec, which mandates IEEE-754 equality on + * doubles). The tests below lock in that agreement so any future fix has to touch both + * paths together. + *
    + */ +class FloatBugConfirmationTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + private final Validator celValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .build(); + + // --- B1: floatFormatter renders -0.0 as "0", losing the sign -------------------------------- + + @Test + void floatConstNegZero_messageMatchesBetweenNativeAndCel() throws ValidationException { + // After the floatFormatter fix, both modes report a violation (1.0 != -0.0) with the same + // message — the rule value is rendered as "-0" in both paths. This test locks in parity; + // a regression in floatFormatter (e.g. the sign-bit short-circuit being removed) will fail + // here. + ExampleFloatConstNegZero msg = ExampleFloatConstNegZero.newBuilder().setVal(1.0f).build(); + String nativeMsg = singleViolationMessage(nativeValidator, msg); + String celMsg = singleViolationMessage(celValidator, msg); + + assertThat(nativeMsg).isEqualTo("must equal -0"); + assertThat(celMsg).isEqualTo("must equal -0"); + } + + @Test + void doubleConstNegZero_messageMatchesBetweenNativeAndCel() throws ValidationException { + ExampleDoubleConstNegZero msg = ExampleDoubleConstNegZero.newBuilder().setVal(1.0).build(); + String nativeMsg = singleViolationMessage(nativeValidator, msg); + String celMsg = singleViolationMessage(celValidator, msg); + + assertThat(nativeMsg).isEqualTo("must equal -0"); + assertThat(celMsg).isEqualTo("must equal -0"); + } + + // --- B2: repeated.unique on floats — native and CEL agree (both wrong vs spec) -------------- + // + // CustomOverload.uniqueList (the CEL-side `unique()` registered by protovalidate-java) is + // implemented with HashSet + Object.equals — the same approach as RepeatedRulesEvaluator's + // native path. Both treat NaN as duplicate (Java equality) and +0.0/-0.0 as distinct + // (floatToIntBits), contradicting CEL's spec (IEEE-754: NaN != NaN, +0.0 == -0.0). + // + // These tests assert the agreement so any IEEE-754 fix has to ship in CustomOverload.uniqueList + // and RepeatedRulesEvaluator.isUnique together — otherwise NativeRulesParityTest will start + // failing. + + @Test + void floatRepeatedUnique_NaNNaN_bothPathsAgreeBothWrongVsSpec() throws ValidationException { + ExampleFloatRepeatedUnique msg = + ExampleFloatRepeatedUnique.newBuilder().addVal(Float.NaN).addVal(Float.NaN).build(); + assertViolationsEqual(msg); + } + + @Test + void doubleRepeatedUnique_NaNNaN_bothPathsAgreeBothWrongVsSpec() throws ValidationException { + ExampleDoubleRepeatedUnique msg = + ExampleDoubleRepeatedUnique.newBuilder().addVal(Double.NaN).addVal(Double.NaN).build(); + assertViolationsEqual(msg); + } + + @Test + void floatRepeatedUnique_PlusZeroMinusZero_bothPathsAgreeBothWrongVsSpec() + throws ValidationException { + ExampleFloatRepeatedUnique msg = + ExampleFloatRepeatedUnique.newBuilder().addVal(0.0f).addVal(-0.0f).build(); + assertViolationsEqual(msg); + } + + @Test + void doubleRepeatedUnique_PlusZeroMinusZero_bothPathsAgreeBothWrongVsSpec() + throws ValidationException { + ExampleDoubleRepeatedUnique msg = + ExampleDoubleRepeatedUnique.newBuilder().addVal(0.0).addVal(-0.0).build(); + assertViolationsEqual(msg); + } + + @Test + void floatDoubleNaNNegZero() throws ValidationException { + // these tests are also checking that an unset (zero) field is equal to -0 + FloatDoubleNaNNegZero nanMsg = FloatDoubleNaNNegZero.newBuilder(). + addDvals(Double.NaN).addDvals(Double.NaN). + addFvals(Float.NaN).addFvals(Float.NaN). + build(); + // should both be no error, since NaN is not equal to itself + // it's not because Java CEL is broken so replicate broken behavior + ValidationResult nanMsgResultNative = nativeValidator.validate(nanMsg); + ValidationResult nanMsgResultCEL = celValidator.validate(nanMsg); + assertViolationsEqual(nanMsg); + assertThat(nanMsgResultNative.getViolations()).isNotEmpty(); + assertThat(nanMsgResultCEL.getViolations()).isNotEmpty(); + + // now check -0 and 0 for uniqueness (should not be) + FloatDoubleNaNNegZero zeroMsg = FloatDoubleNaNNegZero.newBuilder(). + addDvals(0.0).addDvals(-0.0). + addFvals(0.0F).addFvals(-0.0F). + build(); + // should both be error, since 0 == -0 + // but it's not because Java CEL is broken on unique tests for -0 so replicate broken behavior + nanMsgResultNative = nativeValidator.validate(zeroMsg); + nanMsgResultCEL = celValidator.validate(zeroMsg); + assertViolationsEqual(zeroMsg); + assertThat(nanMsgResultNative.getViolations()).isEmpty(); + assertThat(nanMsgResultCEL.getViolations()).isEmpty(); + } + + // --- helpers ---------------------------------------------------------------------------------- + + private static String singleViolationMessage(Validator v, Message msg) + throws ValidationException { + ValidationResult result = v.validate(msg); + List messages = + result.getViolations().stream() + .map(violation -> violation.toProto().getMessage()) + .collect(Collectors.toList()); + assertThat(messages).hasSize(1); + return messages.get(0); + } + + private void assertViolationsEqual(Message msg) throws ValidationException { + List nativeProtos = toProtoList(nativeValidator.validate(msg)); + List celProtos = toProtoList(celValidator.validate(msg)); + assertThat(nativeProtos) + .as("native and CEL must produce identical Violation protos for %s", msg) + .isEqualTo(celProtos); + } + + private static List toProtoList(ValidationResult result) { + return result.getViolations().stream().map(v -> v.toProto()).collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index 9cf613a9..9fd0d56d 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -238,3 +238,38 @@ message ExampleMapMinMax { max_pairs: 3 }]; } + +// --- Float/double parity fixtures --- +// +// See NATIVE_RULES_REVIEW.md sections B1 (fixed) and B2 (reclassified). The tests in +// FloatBugConfirmationTest now lock in parity rather than confirm divergence. + +// Originally documented the floatFormatter sign-strip bug; now used by the regression test +// asserting native and CEL both render -0.0 as "must equal -0". +message ExampleFloatConstNegZero { + float val = 1 [(buf.validate.field).float.const = -0.0]; +} + +message ExampleDoubleConstNegZero { + double val = 1 [(buf.validate.field).double.const = -0.0]; +} + +// Originally claimed a parity divergence on repeated.unique for floats/doubles. Investigation +// showed CustomOverload.uniqueList (the CEL-side impl in protovalidate-java) and +// RepeatedRulesEvaluator.isUnique both use HashSet + Object.equals, so they agree — both +// deviate from CEL's IEEE-754 spec for NaN and +/- 0 in the same direction. The agreement +// tests pin that contract until a coordinated fix lands. +message ExampleFloatRepeatedUnique { + repeated float val = 1 [(buf.validate.field).repeated.unique = true]; +} + +message ExampleDoubleRepeatedUnique { + repeated double val = 1 [(buf.validate.field).repeated.unique = true]; +} + +message FloatDoubleNaNNegZero { + repeated float fvals = 1 [(buf.validate.field).repeated.unique = true]; + repeated double dvals = 2 [(buf.validate.field).repeated.unique = true]; + float fneg_zero = 3 [(buf.validate.field).float.const = -0.0]; + double dneg_zero = 4 [(buf.validate.field).double.const = -0.0]; +} From 58049a38be233db3266860225423d565e1822053 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 1 May 2026 16:27:08 -0400 Subject: [PATCH 14/31] Address code-review action items in native rule evaluators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StringRulesEvaluator: - Pre-build well_known_regex sites (header_name / header_name_empty / header_value) as static finals; checkKnownRegex no longer allocates RuleSites per violation. - Drop defensive ArrayList copies of in/not_in lists; the proto runtime already returns immutable views. - Replace strVal.getBytes(UTF_8).length with a counting helper that doesn't allocate a byte array. - Store WellKnownFormat empty messages explicitly per constant rather than deriving them by string substitution. Substring derivation produced "value is empty, which is not a valid host (hostname or IP address) and port pair" for host_and_port, where the spec says "value is empty, which is not a valid host and port pair". - Drop unused ruleSuffix field on WellKnownFormat. BytesRulesEvaluator / EnumRulesEvaluator: drop defensive in/not_in copies. NumericRulesEvaluator: - Switch evaluate() from eager `new ArrayList<>(0)` to the lazy-null pattern used by the other evaluators; rename finalize → done. - Drop the rulesField parameter from tryBuild; the descriptor now lives on NumericTypeConfig. NumericTypeConfig: - Carry the FieldRules-level field descriptor on each kind. Lets the dispatcher read and clear the typed sub-message without the 12-arm rulesFieldNumberFor switch. - Extract a private create(...) helper to make the static initializers uniform. - Document the FieldRules / per-kind *Rules class-init invariant. Rules: - Drop rulesFieldNumberFor entirely; numericTryBuild reads the descriptor off the config. - Inline tryBuildRepeatedRules / tryBuildMapRules at their single call sites. MapRulesEvaluator: tautology() returns false unconditionally. The previous condition was unreachable because tryBuild already returns null when both fields are unset. WrappedValueEvaluator: assert at construction that the inner field is field number 1 named "value" (the wrapper-message contract). --- .../rules/BytesRulesEvaluator.java | 11 +- .../rules/EnumRulesEvaluator.java | 18 +- .../rules/MapRulesEvaluator.java | 5 +- .../rules/NumericRulesEvaluator.java | 84 ++++--- .../rules/NumericTypeConfig.java | 204 ++++++++-------- .../build/buf/protovalidate/rules/Rules.java | 34 +-- .../rules/StringRulesEvaluator.java | 218 +++++++++++++----- .../rules/WrappedValueEvaluator.java | 8 + 8 files changed, 341 insertions(+), 241 deletions(-) diff --git a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java index 0490936f..aa93cd72 100644 --- a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java @@ -298,19 +298,14 @@ private BytesRulesEvaluator( hasRule = true; } - List inVals = - rules.getInList().isEmpty() - ? Collections.emptyList() - : new ArrayList<>(rules.getInList()); + // Proto returns immutable views; we only read them. + List inVals = rules.getInList(); if (!inVals.isEmpty()) { bb.clearIn(); hasRule = true; } - List notInVals = - rules.getNotInList().isEmpty() - ? Collections.emptyList() - : new ArrayList<>(rules.getNotInList()); + List notInVals = rules.getNotInList(); if (!notInVals.isEmpty()) { bb.clearNotIn(); hasRule = true; diff --git a/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java index 875f8637..5c62a41c 100644 --- a/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java @@ -23,7 +23,6 @@ import com.google.protobuf.Descriptors.EnumValueDescriptor; import com.google.protobuf.Descriptors.FieldDescriptor; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import org.jspecify.annotations.Nullable; @@ -86,19 +85,14 @@ private EnumRulesEvaluator( hasRule = true; } - List inVals = - enumRules.getInList().isEmpty() - ? Collections.emptyList() - : new ArrayList<>(enumRules.getInList()); + // Proto returns immutable views; we only read them. + List inVals = enumRules.getInList(); if (!inVals.isEmpty()) { eb.clearIn(); hasRule = true; } - List notInVals = - enumRules.getNotInList().isEmpty() - ? Collections.emptyList() - : new ArrayList<>(enumRules.getNotInList()); + List notInVals = enumRules.getNotInList(); if (!notInVals.isEmpty()) { eb.clearNotIn(); hasRule = true; @@ -143,7 +137,11 @@ public List evaluate(Value val, boolean failFast) { if (!notInVals.isEmpty() && notInVals.contains(actual)) { RuleViolation.Builder b = NativeViolations.newViolation( - NOT_IN_SITE, null, "must not be in list " + formatList(notInVals), val, actual); + NOT_IN_SITE, + null, + "must not be in list " + formatList(notInVals), + val, + actual); violations = add(violations, b); if (failFast) { return done(violations); diff --git a/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java index 80bb8102..3720fab1 100644 --- a/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java @@ -93,7 +93,10 @@ private MapRulesEvaluator(RuleBase base, @Nullable Long minPairs, @Nullable Long @Override public boolean tautology() { - return minPairs == null && maxPairs == null; + // tryBuild returns null when neither field is set, so this evaluator is never built + // without at least one rule active. Match the rest of the rules package by returning false + // unconditionally. + return false; } @Override diff --git a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java index fa836e5f..bd8c5e68 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java @@ -99,10 +99,8 @@ private NumericRulesEvaluator( * field on {@code FieldRules}) */ static > @Nullable Evaluator tryBuild( - RuleBase base, - FieldRules.Builder rulesBuilder, - NumericTypeConfig config, - FieldDescriptor rulesField) { + RuleBase base, FieldRules.Builder rulesBuilder, NumericTypeConfig config) { + FieldDescriptor rulesField = config.rulesField; if (!rulesBuilder.hasField(rulesField)) { return null; } @@ -189,44 +187,50 @@ public boolean tautology() { @Override public List evaluate(Value val, boolean failFast) { T actual = config.valueClass.cast(val.rawValue()); - List violations = new ArrayList<>(0); + List violations = null; if (constVal != null && config.comparator.compare(actual, constVal) != 0) { - violations.add( - NativeViolations.newViolation( - config.descriptors.constSite, - null, - "must equal " + config.formatter.apply(constVal), - val, - constVal)); + violations = + add( + violations, + NativeViolations.newViolation( + config.descriptors.constSite, + null, + "must equal " + config.formatter.apply(constVal), + val, + constVal)); if (failFast) { - return finalize(violations.subList(0, 1)); + return done(violations); } } if (!inVals.isEmpty() && !containsValue(inVals, actual)) { - violations.add( - NativeViolations.newViolation( - config.descriptors.inSite, - null, - "must be in list " + formatList(inVals), - val, - actual)); + violations = + add( + violations, + NativeViolations.newViolation( + config.descriptors.inSite, + null, + "must be in list " + formatList(inVals), + val, + actual)); if (failFast) { - return finalize(violations.subList(0, 1)); + return done(violations); } } if (!notInVals.isEmpty() && containsValue(notInVals, actual)) { - violations.add( - NativeViolations.newViolation( - config.descriptors.notInSite, - null, - "must not be in list " + formatList(notInVals), - val, - actual)); + violations = + add( + violations, + NativeViolations.newViolation( + config.descriptors.notInSite, + null, + "must not be in list " + formatList(notInVals), + val, + actual)); if (failFast) { - return finalize(violations.subList(0, 1)); + return done(violations); } } @@ -235,23 +239,24 @@ public List evaluate(Value val, boolean failFast) { RuleSite site = Objects.requireNonNull( config.descriptors.finiteSite, "finiteSite must be set when finite is true"); - violations.add(NativeViolations.newViolation(site, null, null, val, actual)); + violations = + add(violations, NativeViolations.newViolation(site, null, null, val, actual)); if (failFast) { - return finalize(violations.subList(0, 1)); + return done(violations); } } if (lowerKind != LowerBound.NONE || upperKind != UpperBound.NONE) { RuleViolation.Builder rangeViolation = buildRangeViolation(val, actual); if (rangeViolation != null) { - violations.add(rangeViolation); + violations = add(violations, rangeViolation); if (failFast) { - return finalize(violations.subList(0, 1)); + return done(violations); } } } - return finalize(violations); + return done(violations); } // --- Per-rule violation builders --- @@ -411,7 +416,16 @@ private String formatList(List vals) { // --- Violation list bookkeeping --- - private List finalize(@Nullable List violations) { + private static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + private List done(@Nullable List violations) { if (violations == null || violations.isEmpty()) { return RuleViolation.NO_VIOLATIONS; } diff --git a/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java b/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java index 3d141493..464722c9 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java +++ b/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java @@ -70,210 +70,224 @@ final class NumericTypeConfig> { */ final boolean nanFailsRange; + /** + * The {@link FieldRules} field descriptor for this numeric kind (e.g. the {@code int32} field on + * {@code FieldRules}). Used by the dispatcher to read and clear the typed rules sub-message. + */ + final FieldDescriptor rulesField; + private NumericTypeConfig( String typeName, NumericDescriptors descriptors, Class valueClass, Comparator comparator, Function formatter, - boolean nanFailsRange) { + boolean nanFailsRange, + FieldDescriptor rulesField) { this.typeName = typeName; this.descriptors = descriptors; this.valueClass = valueClass; this.comparator = comparator; this.formatter = formatter; this.nanFailsRange = nanFailsRange; + this.rulesField = rulesField; } // --- Static configs, one per proto numeric kind --- + // + // Class-init invariant: every static config below transitively calls FieldRules.getDescriptor() + // and the per-kind *Rules.getDescriptor() through frField() and NumericDescriptors.build(). For + // class-loading to succeed, FieldRules and the per-kind rules messages must be loadable when + // NumericTypeConfig is. They are — FieldRules is touched by every entry into EvaluatorBuilder, + // and the per-kind rules messages live in the same generated bundle. If a future change moves + // NumericTypeConfig's initialization earlier (e.g. via a static reference from a class loaded + // before FieldRules), expect NoClassDefFoundError on this class. private static FieldDescriptor frField(int number) { return FieldRules.getDescriptor().findFieldByNumber(number); } + /** Builds a NumericTypeConfig and exposes the FieldRules-level field descriptor on it. */ + private static > NumericTypeConfig create( + String typeName, + int fieldRulesFieldNumber, + com.google.protobuf.Descriptors.Descriptor rulesDescriptor, + Class valueClass, + Comparator comparator, + Function formatter, + boolean nanFailsRange) { + FieldDescriptor rulesField = frField(fieldRulesFieldNumber); + return new NumericTypeConfig<>( + typeName, + NumericDescriptors.build(rulesField, rulesDescriptor, typeName, nanFailsRange), + valueClass, + comparator, + formatter, + nanFailsRange, + rulesField); + } + static final NumericTypeConfig INT32 = - new NumericTypeConfig<>( + create( "int32", - NumericDescriptors.build( - frField(FieldRules.INT32_FIELD_NUMBER), Int32Rules.getDescriptor(), "int32", false), + FieldRules.INT32_FIELD_NUMBER, + Int32Rules.getDescriptor(), Integer.class, Integer::compare, String::valueOf, false); static final NumericTypeConfig SINT32 = - new NumericTypeConfig<>( + create( "sint32", - NumericDescriptors.build( - frField(FieldRules.SINT32_FIELD_NUMBER), - SInt32Rules.getDescriptor(), - "sint32", - false), + FieldRules.SINT32_FIELD_NUMBER, + SInt32Rules.getDescriptor(), Integer.class, Integer::compare, String::valueOf, false); static final NumericTypeConfig SFIXED32 = - new NumericTypeConfig<>( + create( "sfixed32", - NumericDescriptors.build( - frField(FieldRules.SFIXED32_FIELD_NUMBER), - SFixed32Rules.getDescriptor(), - "sfixed32", - false), + FieldRules.SFIXED32_FIELD_NUMBER, + SFixed32Rules.getDescriptor(), Integer.class, Integer::compare, String::valueOf, false); static final NumericTypeConfig UINT32 = - new NumericTypeConfig<>( + create( "uint32", - NumericDescriptors.build( - frField(FieldRules.UINT32_FIELD_NUMBER), - UInt32Rules.getDescriptor(), - "uint32", - false), + FieldRules.UINT32_FIELD_NUMBER, + UInt32Rules.getDescriptor(), Integer.class, Integer::compareUnsigned, Integer::toUnsignedString, false); static final NumericTypeConfig FIXED32 = - new NumericTypeConfig<>( + create( "fixed32", - NumericDescriptors.build( - frField(FieldRules.FIXED32_FIELD_NUMBER), - Fixed32Rules.getDescriptor(), - "fixed32", - false), + FieldRules.FIXED32_FIELD_NUMBER, + Fixed32Rules.getDescriptor(), Integer.class, Integer::compareUnsigned, Integer::toUnsignedString, false); static final NumericTypeConfig INT64 = - new NumericTypeConfig<>( + create( "int64", - NumericDescriptors.build( - frField(FieldRules.INT64_FIELD_NUMBER), Int64Rules.getDescriptor(), "int64", false), + FieldRules.INT64_FIELD_NUMBER, + Int64Rules.getDescriptor(), Long.class, Long::compare, String::valueOf, false); static final NumericTypeConfig SINT64 = - new NumericTypeConfig<>( + create( "sint64", - NumericDescriptors.build( - frField(FieldRules.SINT64_FIELD_NUMBER), - SInt64Rules.getDescriptor(), - "sint64", - false), + FieldRules.SINT64_FIELD_NUMBER, + SInt64Rules.getDescriptor(), Long.class, Long::compare, String::valueOf, false); static final NumericTypeConfig SFIXED64 = - new NumericTypeConfig<>( + create( "sfixed64", - NumericDescriptors.build( - frField(FieldRules.SFIXED64_FIELD_NUMBER), - SFixed64Rules.getDescriptor(), - "sfixed64", - false), + FieldRules.SFIXED64_FIELD_NUMBER, + SFixed64Rules.getDescriptor(), Long.class, Long::compare, String::valueOf, false); static final NumericTypeConfig UINT64 = - new NumericTypeConfig<>( + create( "uint64", - NumericDescriptors.build( - frField(FieldRules.UINT64_FIELD_NUMBER), - UInt64Rules.getDescriptor(), - "uint64", - false), + FieldRules.UINT64_FIELD_NUMBER, + UInt64Rules.getDescriptor(), Long.class, Long::compareUnsigned, Long::toUnsignedString, false); static final NumericTypeConfig FIXED64 = - new NumericTypeConfig<>( + create( "fixed64", - NumericDescriptors.build( - frField(FieldRules.FIXED64_FIELD_NUMBER), - Fixed64Rules.getDescriptor(), - "fixed64", - false), + FieldRules.FIXED64_FIELD_NUMBER, + Fixed64Rules.getDescriptor(), Long.class, Long::compareUnsigned, Long::toUnsignedString, false); static final NumericTypeConfig FLOAT = - new NumericTypeConfig<>( + create( "float", - NumericDescriptors.build( - frField(FieldRules.FLOAT_FIELD_NUMBER), FloatRules.getDescriptor(), "float", true), + FieldRules.FLOAT_FIELD_NUMBER, + FloatRules.getDescriptor(), Float.class, NumericTypeConfig::floatCompare, NumericTypeConfig::floatFormatter, true); static final NumericTypeConfig DOUBLE = - new NumericTypeConfig<>( + create( "double", - NumericDescriptors.build( - frField(FieldRules.DOUBLE_FIELD_NUMBER), DoubleRules.getDescriptor(), "double", true), + FieldRules.DOUBLE_FIELD_NUMBER, + DoubleRules.getDescriptor(), Double.class, NumericTypeConfig::doubleCompare, NumericTypeConfig::doubleFormatter, true); - public static int floatCompare(Float f1, Float f2) { - // this makes sure 0 == -0 - if((f1 == 0.0)&&(f2 == 0.0)) { - return 0; - } - return f1.compareTo(f2); + // Float and double comparators treat +0.0 and -0.0 as equal, matching IEEE-754. NaN keeps + // Java's compareTo semantics (NaN.compareTo(NaN) == 0) so behavior matches the existing + // protovalidate-java CEL path, which uses the same Object.equals semantics. + + private static int floatCompare(Float f1, Float f2) { + if (f1 == 0.0f && f2 == 0.0f) { + return 0; + } + return f1.compareTo(f2); } - public static int doubleCompare(Double f1, Double f2) { - // this makes sure 0 == -0 - if(f1 == 0.0 && f2 == 0.0) { - return 0; - } - return f1.compareTo(f2); + private static int doubleCompare(Double d1, Double d2) { + if (d1 == 0.0 && d2 == 0.0) { + return 0; + } + return d1.compareTo(d2); } - public static String floatFormatter(Float f) { - // if the float is -0, print it as -0 - if (Float.floatToIntBits(f) == 1<<31) { - return "-0"; - } - // if the float is a whole number, don't print the decimal - float f2 = f.intValue(); - if (f2 == f) { - return String.valueOf(f.intValue()); - } - return String.valueOf(f); + private static final int FLOAT_NEG_ZERO_BITS = Float.floatToIntBits(-0.0f); + private static final long DOUBLE_NEG_ZERO_BITS = Double.doubleToLongBits(-0.0); + + private static String floatFormatter(Float f) { + if (Float.floatToIntBits(f) == FLOAT_NEG_ZERO_BITS) { + return "-0"; + } + // Whole-number short-circuit: print "5" rather than "5.0" to match Go's %g behavior. + float asInt = f.intValue(); + if (asInt == f) { + return String.valueOf(f.intValue()); + } + return String.valueOf(f); } - public static String doubleFormatter(Double d) { - // if the double is -0, print it as -0 - if (Double.doubleToLongBits(d) == 1L<<63) { - return "-0"; - } - // if the double is a whole number, don't print the decimal - double d2 = d.intValue(); - if (d2 == d) { - return String.valueOf(d.intValue()); - } - return String.valueOf(d); + private static String doubleFormatter(Double d) { + if (Double.doubleToLongBits(d) == DOUBLE_NEG_ZERO_BITS) { + return "-0"; + } + double asInt = d.intValue(); + if (asInt == d) { + return String.valueOf(d.intValue()); + } + return String.valueOf(d); } } diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index 3c72698d..35071bd1 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -56,10 +56,10 @@ private Rules() {} ValueEvaluator valueEvaluator) { boolean hasNestedRule = valueEvaluator.hasNestedRule(); if (fieldDescriptor.isMapField() && !hasNestedRule) { - return tryBuildMapRules(rulesBuilder, valueEvaluator); + return MapRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); } if (fieldDescriptor.isRepeated() && !hasNestedRule) { - return tryBuildRepeatedRules(rulesBuilder, valueEvaluator); + return RepeatedRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); } if (!fieldDescriptor.isMapField() && !fieldDescriptor.isRepeated()) { Evaluator scalar = tryBuildScalarRules(fieldDescriptor, rulesBuilder, valueEvaluator); @@ -146,35 +146,7 @@ private Rules() {} @SuppressWarnings({"rawtypes", "unchecked"}) private static @Nullable Evaluator numericTryBuild( RuleBase base, FieldRules.Builder rulesBuilder, NumericTypeConfig config) { - FieldDescriptor rulesField = - FieldRules.getDescriptor().findFieldByNumber(rulesFieldNumberFor(config)); - return NumericRulesEvaluator.tryBuild( - base, rulesBuilder, (NumericTypeConfig) config, rulesField); + return NumericRulesEvaluator.tryBuild(base, rulesBuilder, (NumericTypeConfig) config); } - private static int rulesFieldNumberFor(NumericTypeConfig config) { - if (config == NumericTypeConfig.INT32) return FieldRules.INT32_FIELD_NUMBER; - if (config == NumericTypeConfig.SINT32) return FieldRules.SINT32_FIELD_NUMBER; - if (config == NumericTypeConfig.SFIXED32) return FieldRules.SFIXED32_FIELD_NUMBER; - if (config == NumericTypeConfig.UINT32) return FieldRules.UINT32_FIELD_NUMBER; - if (config == NumericTypeConfig.FIXED32) return FieldRules.FIXED32_FIELD_NUMBER; - if (config == NumericTypeConfig.INT64) return FieldRules.INT64_FIELD_NUMBER; - if (config == NumericTypeConfig.SINT64) return FieldRules.SINT64_FIELD_NUMBER; - if (config == NumericTypeConfig.SFIXED64) return FieldRules.SFIXED64_FIELD_NUMBER; - if (config == NumericTypeConfig.UINT64) return FieldRules.UINT64_FIELD_NUMBER; - if (config == NumericTypeConfig.FIXED64) return FieldRules.FIXED64_FIELD_NUMBER; - if (config == NumericTypeConfig.FLOAT) return FieldRules.FLOAT_FIELD_NUMBER; - if (config == NumericTypeConfig.DOUBLE) return FieldRules.DOUBLE_FIELD_NUMBER; - throw new IllegalArgumentException("unknown numeric config"); - } - - private static @Nullable Evaluator tryBuildRepeatedRules( - FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { - return RepeatedRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); - } - - private static @Nullable Evaluator tryBuildMapRules( - FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { - return MapRulesEvaluator.tryBuild(RuleBase.of(valueEvaluator), rulesBuilder); - } } diff --git a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java index fa37172c..a6321ac8 100644 --- a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java @@ -24,10 +24,9 @@ import build.buf.validate.StringRules; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.re2j.Pattern; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.Objects; import org.jspecify.annotations.Nullable; /** @@ -77,6 +76,28 @@ final class StringRulesEvaluator implements Evaluator { private static final FieldDescriptor WELL_KNOWN_REGEX_DESC = StringRules.getDescriptor().findFieldByNumber(StringRules.WELL_KNOWN_REGEX_FIELD_NUMBER); + // well_known_regex sites: pre-built once. Header-name and header-value have a normal-failure + // site and an empty-input site (header_name); pattern selection at evaluation time picks + // between strict and loose matchers. + private static final RuleSite HEADER_NAME_SITE = + RuleSite.of( + STRING_RULES_DESC, + WELL_KNOWN_REGEX_DESC, + "string.well_known_regex.header_name", + "must be a valid HTTP header name"); + private static final RuleSite HEADER_NAME_EMPTY_SITE = + RuleSite.of( + STRING_RULES_DESC, + WELL_KNOWN_REGEX_DESC, + "string.well_known_regex.header_name_empty", + "value is empty, which is not a valid HTTP header name"); + private static final RuleSite HEADER_VALUE_SITE = + RuleSite.of( + STRING_RULES_DESC, + WELL_KNOWN_REGEX_DESC, + "string.well_known_regex.header_value", + "must be a valid HTTP header value"); + private static RuleSite site(int fieldNumber, String ruleId) { FieldDescriptor leaf = StringRules.getDescriptor().findFieldByNumber(fieldNumber); return RuleSite.of(STRING_RULES_DESC, leaf, ruleId, null); @@ -98,46 +119,77 @@ private static RuleSite site(int fieldNumber, String ruleId) { // --- Well-known string formats --- - /** Each constant carries the rule id, message, empty-value variant, and validation. */ + /** + * Each constant carries the rule id, the main violation message, the empty-value variant + * message, and the validation. Empty messages are stored verbatim from the proto spec rather + * than derived by string substitution: {@code host_and_port}, for example, has a main message + * of {@code "must be a valid host (hostname or IP address) and port pair"} but an empty message + * of {@code "value is empty, which is not a valid host and port pair"} (without the + * parenthetical) — substring derivation produced the wrong text. + */ @SuppressWarnings("ImmutableEnumChecker") // RuleSite is logically immutable; not annotated. enum WellKnownFormat { - EMAIL(StringRules.EMAIL_FIELD_NUMBER, "email", "must be a valid email address") { + EMAIL( + StringRules.EMAIL_FIELD_NUMBER, + "email", + "must be a valid email address", + "value is empty, which is not a valid email address") { @Override boolean validate(String s) { return CustomOverload.isEmail(s); } }, - HOSTNAME(StringRules.HOSTNAME_FIELD_NUMBER, "hostname", "must be a valid hostname") { + HOSTNAME( + StringRules.HOSTNAME_FIELD_NUMBER, + "hostname", + "must be a valid hostname", + "value is empty, which is not a valid hostname") { @Override boolean validate(String s) { return CustomOverload.isHostname(s); } }, - IP(StringRules.IP_FIELD_NUMBER, "ip", "must be a valid IP address") { + IP( + StringRules.IP_FIELD_NUMBER, + "ip", + "must be a valid IP address", + "value is empty, which is not a valid IP address") { @Override boolean validate(String s) { return CustomOverload.isIp(s, 0); } }, - IPV4(StringRules.IPV4_FIELD_NUMBER, "ipv4", "must be a valid IPv4 address") { + IPV4( + StringRules.IPV4_FIELD_NUMBER, + "ipv4", + "must be a valid IPv4 address", + "value is empty, which is not a valid IPv4 address") { @Override boolean validate(String s) { return CustomOverload.isIp(s, 4); } }, - IPV6(StringRules.IPV6_FIELD_NUMBER, "ipv6", "must be a valid IPv6 address") { + IPV6( + StringRules.IPV6_FIELD_NUMBER, + "ipv6", + "must be a valid IPv6 address", + "value is empty, which is not a valid IPv6 address") { @Override boolean validate(String s) { return CustomOverload.isIp(s, 6); } }, - URI(StringRules.URI_FIELD_NUMBER, "uri", "must be a valid URI") { + URI( + StringRules.URI_FIELD_NUMBER, + "uri", + "must be a valid URI", + "value is empty, which is not a valid URI") { @Override boolean validate(String s) { return CustomOverload.isUri(s); } }, - URI_REF(StringRules.URI_REF_FIELD_NUMBER, "uri_ref", "must be a valid URI Reference") { + URI_REF(StringRules.URI_REF_FIELD_NUMBER, "uri_ref", "must be a valid URI Reference", null) { @Override boolean validate(String s) { return CustomOverload.isUriRef(s); @@ -149,19 +201,30 @@ boolean checksEmpty() { } }, ADDRESS( - StringRules.ADDRESS_FIELD_NUMBER, "address", "must be a valid hostname, or ip address") { + StringRules.ADDRESS_FIELD_NUMBER, + "address", + "must be a valid hostname, or ip address", + "value is empty, which is not a valid hostname, or ip address") { @Override boolean validate(String s) { return CustomOverload.isHostname(s) || CustomOverload.isIp(s, 0); } }, - UUID(StringRules.UUID_FIELD_NUMBER, "uuid", "must be a valid UUID") { + UUID( + StringRules.UUID_FIELD_NUMBER, + "uuid", + "must be a valid UUID", + "value is empty, which is not a valid UUID") { @Override boolean validate(String s) { return UUID_REGEX.matches(s); } }, - TUUID(StringRules.TUUID_FIELD_NUMBER, "tuuid", "must be a valid trimmed UUID") { + TUUID( + StringRules.TUUID_FIELD_NUMBER, + "tuuid", + "must be a valid trimmed UUID", + "value is empty, which is not a valid trimmed UUID") { @Override boolean validate(String s) { return TUUID_REGEX.matches(s); @@ -170,7 +233,8 @@ boolean validate(String s) { IP_WITH_PREFIXLEN( StringRules.IP_WITH_PREFIXLEN_FIELD_NUMBER, "ip_with_prefixlen", - "must be a valid IP prefix") { + "must be a valid IP prefix", + "value is empty, which is not a valid IP prefix") { @Override boolean validate(String s) { return CustomOverload.isIpPrefix(s, 0, false); @@ -179,7 +243,8 @@ boolean validate(String s) { IPV4_WITH_PREFIXLEN( StringRules.IPV4_WITH_PREFIXLEN_FIELD_NUMBER, "ipv4_with_prefixlen", - "must be a valid IPv4 address with prefix length") { + "must be a valid IPv4 address with prefix length", + "value is empty, which is not a valid IPv4 address with prefix length") { @Override boolean validate(String s) { return CustomOverload.isIpPrefix(s, 4, false); @@ -188,27 +253,38 @@ boolean validate(String s) { IPV6_WITH_PREFIXLEN( StringRules.IPV6_WITH_PREFIXLEN_FIELD_NUMBER, "ipv6_with_prefixlen", - "must be a valid IPv6 address with prefix length") { + "must be a valid IPv6 address with prefix length", + "value is empty, which is not a valid IPv6 address with prefix length") { @Override boolean validate(String s) { return CustomOverload.isIpPrefix(s, 6, false); } }, - IP_PREFIX(StringRules.IP_PREFIX_FIELD_NUMBER, "ip_prefix", "must be a valid IP prefix") { + IP_PREFIX( + StringRules.IP_PREFIX_FIELD_NUMBER, + "ip_prefix", + "must be a valid IP prefix", + "value is empty, which is not a valid IP prefix") { @Override boolean validate(String s) { return CustomOverload.isIpPrefix(s, 0, true); } }, IPV4_PREFIX( - StringRules.IPV4_PREFIX_FIELD_NUMBER, "ipv4_prefix", "must be a valid IPv4 prefix") { + StringRules.IPV4_PREFIX_FIELD_NUMBER, + "ipv4_prefix", + "must be a valid IPv4 prefix", + "value is empty, which is not a valid IPv4 prefix") { @Override boolean validate(String s) { return CustomOverload.isIpPrefix(s, 4, true); } }, IPV6_PREFIX( - StringRules.IPV6_PREFIX_FIELD_NUMBER, "ipv6_prefix", "must be a valid IPv6 prefix") { + StringRules.IPV6_PREFIX_FIELD_NUMBER, + "ipv6_prefix", + "must be a valid IPv6 prefix", + "value is empty, which is not a valid IPv6 prefix") { @Override boolean validate(String s) { return CustomOverload.isIpPrefix(s, 6, true); @@ -217,13 +293,18 @@ boolean validate(String s) { HOST_AND_PORT( StringRules.HOST_AND_PORT_FIELD_NUMBER, "host_and_port", - "must be a valid host (hostname or IP address) and port pair") { + "must be a valid host (hostname or IP address) and port pair", + "value is empty, which is not a valid host and port pair") { @Override boolean validate(String s) { return CustomOverload.isHostAndPort(s, true); } }, - ULID(StringRules.ULID_FIELD_NUMBER, "ulid", "must be a valid ULID") { + ULID( + StringRules.ULID_FIELD_NUMBER, + "ulid", + "must be a valid ULID", + "value is empty, which is not a valid ULID") { @Override boolean validate(String s) { return ULID_REGEX.matches(s); @@ -231,25 +312,19 @@ boolean validate(String s) { }; final FieldDescriptor field; - final String ruleSuffix; final RuleSite site; - final RuleSite emptySite; + final @Nullable RuleSite emptySite; - WellKnownFormat(int fieldNumber, String ruleSuffix, String message) { + WellKnownFormat( + int fieldNumber, String ruleSuffix, String message, @Nullable String emptyMessage) { FieldDescriptor leaf = StringRules.getDescriptor().findFieldByNumber(fieldNumber); this.field = leaf; - this.ruleSuffix = ruleSuffix; this.site = RuleSite.of(STRING_RULES_DESC, leaf, "string." + ruleSuffix, message); - // The empty-variant message reads "value is empty, which is not a valid "; build - // it from the format's display name (everything after "must be a valid " in the message, - // minus the trailing comma form for ADDRESS). - String displayName = message.replace("must be a valid ", ""); this.emptySite = - RuleSite.of( - STRING_RULES_DESC, - leaf, - "string." + ruleSuffix + "_empty", - "value is empty, which is not a valid " + displayName); + emptyMessage == null + ? null + : RuleSite.of( + STRING_RULES_DESC, leaf, "string." + ruleSuffix + "_empty", emptyMessage); } /** Whether this format reports an empty-value violation distinctly from the format failure. */ @@ -444,19 +519,15 @@ private StringRulesEvaluator( hasRule = true; } - List inVals = - rules.getInList().isEmpty() - ? Collections.emptyList() - : new ArrayList<>(rules.getInList()); + // getInList()/getNotInList() return immutable views from the proto runtime; we only read + // them, so no defensive copy is needed. + List inVals = rules.getInList(); if (!inVals.isEmpty()) { sb.clearIn(); hasRule = true; } - List notInVals = - rules.getNotInList().isEmpty() - ? Collections.emptyList() - : new ArrayList<>(rules.getNotInList()); + List notInVals = rules.getNotInList(); if (!notInVals.isEmpty()) { sb.clearNotIn(); hasRule = true; @@ -507,7 +578,7 @@ public List evaluate(Value val, boolean failFast) { } if (exactBytes != null || minBytes != null || maxBytes != null) { - long byteCount = strVal.getBytes(StandardCharsets.UTF_8).length; + long byteCount = utf8ByteLength(strVal); violations = applyByteLength(violations, val, byteCount, failFast); if (failFast && violations != null) { return done(violations); @@ -594,7 +665,11 @@ public List evaluate(Value val, boolean failFast) { add( violations, NativeViolations.newViolation( - NOT_IN_SITE, null, "must not be in list " + formatList(notInVals), val, strVal)); + NOT_IN_SITE, + null, + "must not be in list " + formatList(notInVals), + val, + strVal)); if (failFast) return done(violations); } @@ -689,7 +764,10 @@ public List evaluate(Value val, boolean failFast) { return null; } if (fmt.checksEmpty() && strVal.isEmpty()) { - return NativeViolations.newViolation(fmt.emptySite, null, null, val, true); + // checksEmpty() returning true implies emptySite is non-null (the WellKnownFormat + // constructor's contract). + RuleSite emptySite = Objects.requireNonNull(fmt.emptySite); + return NativeViolations.newViolation(emptySite, null, null, val, true); } if (fmt.validate(strVal)) { return null; @@ -699,30 +777,19 @@ public List evaluate(Value val, boolean failFast) { private RuleViolation.@Nullable Builder checkKnownRegex(String strVal, Value val) { Pattern matcher; - String ruleId; - String message; + RuleSite site; switch (knownRegex) { case KNOWN_REGEX_HTTP_HEADER_NAME: if (strVal.isEmpty()) { return NativeViolations.newViolation( - RuleSite.of( - STRING_RULES_DESC, - WELL_KNOWN_REGEX_DESC, - "string.well_known_regex.header_name_empty", - "value is empty, which is not a valid HTTP header name"), - null, - null, - val, - knownRegex.getNumber()); + HEADER_NAME_EMPTY_SITE, null, null, val, knownRegex.getNumber()); } matcher = HEADER_NAME_REGEX; - ruleId = "string.well_known_regex.header_name"; - message = "must be a valid HTTP header name"; + site = HEADER_NAME_SITE; break; case KNOWN_REGEX_HTTP_HEADER_VALUE: matcher = HEADER_VALUE_REGEX; - ruleId = "string.well_known_regex.header_value"; - message = "must be a valid HTTP header value"; + site = HEADER_VALUE_SITE; break; default: return null; @@ -731,7 +798,6 @@ public List evaluate(Value val, boolean failFast) { matcher = LOOSE_REGEX; } if (!matcher.matches(strVal)) { - RuleSite site = RuleSite.of(STRING_RULES_DESC, WELL_KNOWN_REGEX_DESC, ruleId, message); return NativeViolations.newViolation(site, null, null, val, knownRegex.getNumber()); } return null; @@ -739,6 +805,36 @@ public List evaluate(Value val, boolean failFast) { // --- Helpers --- + /** + * Returns the number of bytes in the UTF-8 encoding of {@code s} without allocating the byte + * array. Counts each code point's encoded byte length: 1 for U+0000–U+007F, 2 for U+0080–U+07FF, + * 3 for U+0800–U+FFFF (excluding the surrogate range, which is consumed as a pair), 4 for + * supplementary characters (U+10000–U+10FFFF). + */ + private static long utf8ByteLength(String s) { + long count = 0; + int i = 0; + int len = s.length(); + while (i < len) { + char c = s.charAt(i); + if (c < 0x80) { + count += 1; + i += 1; + } else if (c < 0x800) { + count += 2; + i += 1; + } else if (Character.isHighSurrogate(c) && i + 1 < len + && Character.isLowSurrogate(s.charAt(i + 1))) { + count += 4; + i += 2; + } else { + count += 3; + i += 1; + } + } + return count; + } + private static String formatList(List vals) { StringBuilder sb = new StringBuilder("["); for (int i = 0; i < vals.size(); i++) { diff --git a/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java b/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java index d58d2bfa..de2b64f7 100644 --- a/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java @@ -42,6 +42,14 @@ final class WrappedValueEvaluator implements Evaluator { private final Evaluator inner; WrappedValueEvaluator(FieldDescriptor innerField, Evaluator inner) { + // innerField must be the synthetic "value" field of a google.protobuf.*Value wrapper. Its + // containing message holds exactly one field at number 1 named "value"; if any of those + // assumptions is violated the evaluator would silently misbehave at runtime. + if (innerField.getNumber() != 1 || !"value".equals(innerField.getName())) { + throw new IllegalArgumentException( + "WrappedValueEvaluator requires the wrapper's inner 'value' field, got " + + innerField.getFullName()); + } this.innerField = innerField; this.inner = inner; } From e1b394c7129d31baeaee4214f71e02876dd5aa64 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 1 May 2026 16:28:34 -0400 Subject: [PATCH 15/31] Pull violation-list helpers onto RuleBase, drop per-evaluator wrappers The lazy-null `add(...)`, `done(...)`, and `formatList(...)` helpers were duplicated across six evaluators. Move them onto RuleBase: a static `add(...)`, an instance `done(...)` that finalizes against this base's field-path/rule-prefix elements, and a generic `formatList(...)` plus toString-default overload. Per-evaluator wrappers that were pure delegations are deleted entirely. NumericRulesEvaluator and BytesRulesEvaluator keep their `formatList` methods because each captures a non-default formatter (config.formatter and ByteString::toStringUtf8 respectively). Call sites use `RuleBase.add(...)` / `base.done(...)` / `RuleBase.formatList(...)` directly. --- .../rules/BoolRulesEvaluator.java | 7 +- .../rules/BytesRulesEvaluator.java | 74 +++++---------- .../rules/EnumRulesEvaluator.java | 49 ++-------- .../rules/MapRulesEvaluator.java | 29 +----- .../rules/NumericRulesEvaluator.java | 54 +++-------- .../rules/RepeatedRulesEvaluator.java | 33 ++----- .../buf/protovalidate/rules/RuleBase.java | 51 ++++++++++ .../rules/StringRulesEvaluator.java | 92 +++++++------------ 8 files changed, 143 insertions(+), 246 deletions(-) diff --git a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java index 782581da..c54a8185 100644 --- a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java @@ -15,7 +15,6 @@ package build.buf.protovalidate.rules; import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.FieldPathUtils; import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.Value; import build.buf.validate.BoolRules; @@ -78,11 +77,9 @@ public List evaluate(Value val, boolean failFast) { if (actual == expected) { return RuleViolation.NO_VIOLATIONS; } - List violations = + return base.done( Collections.singletonList( NativeViolations.newViolation( - CONST_SITE, null, "must equal " + expected, val, expected)); - return FieldPathUtils.updatePaths( - violations, base.getFieldPathElement(), base.getRulePrefixElements()); + CONST_SITE, null, "must equal " + expected, val, expected))); } } diff --git a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java index aa93cd72..0a967dd8 100644 --- a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java @@ -15,7 +15,6 @@ package build.buf.protovalidate.rules; import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.FieldPathUtils; import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.Value; import build.buf.protovalidate.exceptions.ExecutionException; @@ -25,7 +24,6 @@ import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.re2j.Pattern; import com.google.re2j.PatternSyntaxException; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -345,38 +343,38 @@ public List evaluate(Value val, boolean failFast) if (constVal != null && !bytesVal.equals(constVal)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( CONST_SITE, null, "must be " + hex(constVal), val, constVal)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (exactLen != null && byteLen != exactLen) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( LEN_SITE, null, "must be " + exactLen + " bytes", val, exactLen)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (minLen != null && byteLen < minLen) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MIN_LEN_SITE, null, "must be at least " + minLen + " bytes", val, minLen)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (maxLen != null && byteLen > maxLen) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MAX_LEN_SITE, null, "must be at most " + maxLen + " bytes", val, maxLen)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (pattern != null) { @@ -387,7 +385,7 @@ public List evaluate(Value val, boolean failFast) } if (!pattern.matches(bytesVal.toStringUtf8())) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( PATTERN_SITE, @@ -395,49 +393,49 @@ public List evaluate(Value val, boolean failFast) "must match regex pattern `" + patternStr + "`", val, patternStr)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } } if (prefix != null && !bytesVal.startsWith(prefix)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( PREFIX_SITE, null, "does not have prefix " + hex(prefix), val, prefix)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (suffix != null && !bytesVal.endsWith(suffix)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( SUFFIX_SITE, null, "does not have suffix " + hex(suffix), val, suffix)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (contains != null && !containsBytes(bytesVal, contains)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( CONTAINS_SITE, null, "does not contain " + hex(contains), val, contains)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (!inVals.isEmpty() && !inVals.contains(bytesVal)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( IN_SITE, null, "must be in list " + formatList(inVals), val, bytesVal)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (!notInVals.isEmpty() && notInVals.contains(bytesVal)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( NOT_IN_SITE, @@ -445,18 +443,18 @@ public List evaluate(Value val, boolean failFast) "must not be in list " + formatList(notInVals), val, bytesVal)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (wellKnown != null) { RuleViolation.Builder wkViolation = evaluateWellKnown(bytesVal, val); if (wkViolation != null) { - violations = add(violations, wkViolation); - if (failFast) return done(violations); + violations = RuleBase.add(violations, wkViolation); + if (failFast) return base.done(violations); } } - return done(violations); + return base.done(violations); } private RuleViolation.@Nullable Builder evaluateWellKnown(ByteString bytesVal, Value val) { @@ -517,31 +515,7 @@ private static String hex(ByteString bs) { * Mirrors Go's {@code formatBytesList}. */ private static String formatList(List vals) { - StringBuilder sb = new StringBuilder("["); - for (int i = 0; i < vals.size(); i++) { - if (i > 0) { - sb.append(", "); - } - sb.append(vals.get(i).toStringUtf8()); - } - sb.append("]"); - return sb.toString(); + return RuleBase.formatList(vals, ByteString::toStringUtf8); } - private static List add( - @Nullable List violations, RuleViolation.Builder v) { - if (violations == null) { - violations = new ArrayList<>(2); - } - violations.add(v); - return violations; - } - - private List done(@Nullable List violations) { - if (violations == null || violations.isEmpty()) { - return RuleViolation.NO_VIOLATIONS; - } - return FieldPathUtils.updatePaths( - violations, base.getFieldPathElement(), base.getRulePrefixElements()); - } } diff --git a/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java index 5c62a41c..36de71f1 100644 --- a/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java @@ -15,14 +15,12 @@ package build.buf.protovalidate.rules; import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.FieldPathUtils; import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.Value; import build.buf.validate.EnumRules; import build.buf.validate.FieldRules; import com.google.protobuf.Descriptors.EnumValueDescriptor; import com.google.protobuf.Descriptors.FieldDescriptor; -import java.util.ArrayList; import java.util.List; import org.jspecify.annotations.Nullable; @@ -118,19 +116,19 @@ public List evaluate(Value val, boolean failFast) { if (constVal != null && actual != constVal) { RuleViolation.Builder b = NativeViolations.newViolation(CONST_SITE, null, "must equal " + constVal, val, constVal); - violations = add(violations, b); + violations = RuleBase.add(violations, b); if (failFast) { - return done(violations); + return base.done(violations); } } if (!inVals.isEmpty() && !inVals.contains(actual)) { RuleViolation.Builder b = NativeViolations.newViolation( - IN_SITE, null, "must be in list " + formatList(inVals), val, actual); - violations = add(violations, b); + IN_SITE, null, "must be in list " + RuleBase.formatList(inVals), val, actual); + violations = RuleBase.add(violations, b); if (failFast) { - return done(violations); + return base.done(violations); } } @@ -139,16 +137,16 @@ public List evaluate(Value val, boolean failFast) { NativeViolations.newViolation( NOT_IN_SITE, null, - "must not be in list " + formatList(notInVals), + "must not be in list " + RuleBase.formatList(notInVals), val, actual); - violations = add(violations, b); + violations = RuleBase.add(violations, b); if (failFast) { - return done(violations); + return base.done(violations); } } - return done(violations); + return base.done(violations); } /** @@ -169,33 +167,4 @@ private static int enumNumber(Object raw) { throw new IllegalStateException( "unexpected enum value representation: " + raw.getClass().getName()); } - - private static List add( - @Nullable List violations, RuleViolation.Builder v) { - if (violations == null) { - violations = new ArrayList<>(2); - } - violations.add(v); - return violations; - } - - private List done(@Nullable List violations) { - if (violations == null || violations.isEmpty()) { - return RuleViolation.NO_VIOLATIONS; - } - return FieldPathUtils.updatePaths( - violations, base.getFieldPathElement(), base.getRulePrefixElements()); - } - - private static String formatList(List vals) { - StringBuilder sb = new StringBuilder("["); - for (int i = 0; i < vals.size(); i++) { - if (i > 0) { - sb.append(", "); - } - sb.append(vals.get(i)); - } - sb.append("]"); - return sb.toString(); - } } diff --git a/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java index 3720fab1..75e6dfa3 100644 --- a/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java @@ -15,13 +15,11 @@ package build.buf.protovalidate.rules; import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.FieldPathUtils; import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; import build.buf.validate.MapRules; import com.google.protobuf.Descriptors.FieldDescriptor; -import java.util.ArrayList; import java.util.List; import org.jspecify.annotations.Nullable; @@ -109,7 +107,7 @@ public List evaluate(Value val, boolean failFast) { if (minPairs != null && size < minPairs) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MIN_PAIRS_SITE, @@ -117,12 +115,12 @@ public List evaluate(Value val, boolean failFast) { "map must be at least " + minPairs + " entries", val, minPairs)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (maxPairs != null && size > maxPairs) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MAX_PAIRS_SITE, @@ -130,26 +128,9 @@ public List evaluate(Value val, boolean failFast) { "map must be at most " + maxPairs + " entries", val, maxPairs)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } - return done(violations); - } - - private static List add( - @Nullable List violations, RuleViolation.Builder v) { - if (violations == null) { - violations = new ArrayList<>(2); - } - violations.add(v); - return violations; - } - - private List done(@Nullable List violations) { - if (violations == null || violations.isEmpty()) { - return RuleViolation.NO_VIOLATIONS; - } - return FieldPathUtils.updatePaths( - violations, base.getFieldPathElement(), base.getRulePrefixElements()); + return base.done(violations); } } diff --git a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java index bd8c5e68..0897c4d4 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java @@ -15,13 +15,11 @@ package build.buf.protovalidate.rules; import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.FieldPathUtils; import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Message; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -191,7 +189,7 @@ public List evaluate(Value val, boolean failFast) { if (constVal != null && config.comparator.compare(actual, constVal) != 0) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( config.descriptors.constSite, @@ -200,13 +198,13 @@ public List evaluate(Value val, boolean failFast) { val, constVal)); if (failFast) { - return done(violations); + return base.done(violations); } } if (!inVals.isEmpty() && !containsValue(inVals, actual)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( config.descriptors.inSite, @@ -215,13 +213,13 @@ public List evaluate(Value val, boolean failFast) { val, actual)); if (failFast) { - return done(violations); + return base.done(violations); } } if (!notInVals.isEmpty() && containsValue(notInVals, actual)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( config.descriptors.notInSite, @@ -230,7 +228,7 @@ public List evaluate(Value val, boolean failFast) { val, actual)); if (failFast) { - return done(violations); + return base.done(violations); } } @@ -240,23 +238,23 @@ public List evaluate(Value val, boolean failFast) { Objects.requireNonNull( config.descriptors.finiteSite, "finiteSite must be set when finite is true"); violations = - add(violations, NativeViolations.newViolation(site, null, null, val, actual)); + RuleBase.add(violations, NativeViolations.newViolation(site, null, null, val, actual)); if (failFast) { - return done(violations); + return base.done(violations); } } if (lowerKind != LowerBound.NONE || upperKind != UpperBound.NONE) { RuleViolation.Builder rangeViolation = buildRangeViolation(val, actual); if (rangeViolation != null) { - violations = add(violations, rangeViolation); + violations = RuleBase.add(violations, rangeViolation); if (failFast) { - return done(violations); + return base.done(violations); } } } - return done(violations); + return base.done(violations); } // --- Per-rule violation builders --- @@ -402,34 +400,8 @@ private String conjunction() { return isNormalRange() ? "and" : "or"; } + /** Renders {@code vals} using this kind's typed formatter. */ private String formatList(List vals) { - StringBuilder sb = new StringBuilder("["); - for (int i = 0; i < vals.size(); i++) { - if (i > 0) { - sb.append(", "); - } - sb.append(config.formatter.apply(vals.get(i))); - } - sb.append("]"); - return sb.toString(); - } - - // --- Violation list bookkeeping --- - - private static List add( - @Nullable List violations, RuleViolation.Builder v) { - if (violations == null) { - violations = new ArrayList<>(2); - } - violations.add(v); - return violations; - } - - private List done(@Nullable List violations) { - if (violations == null || violations.isEmpty()) { - return RuleViolation.NO_VIOLATIONS; - } - return FieldPathUtils.updatePaths( - violations, base.getFieldPathElement(), base.getRulePrefixElements()); + return RuleBase.formatList(vals, config.formatter); } } diff --git a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java index ce02c55f..64ab8cb2 100644 --- a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java @@ -15,13 +15,11 @@ package build.buf.protovalidate.rules; import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.FieldPathUtils; import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; import build.buf.validate.RepeatedRules; import com.google.protobuf.Descriptors.FieldDescriptor; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -158,7 +156,7 @@ public List evaluate(Value val, boolean failFast) { if (minItems != null && size < minItems) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MIN_ITEMS_SITE, @@ -166,12 +164,12 @@ public List evaluate(Value val, boolean failFast) { "must contain at least " + minItems + " item(s)", val, minItems)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (maxItems != null && size > maxItems) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MAX_ITEMS_SITE, @@ -179,16 +177,17 @@ public List evaluate(Value val, boolean failFast) { "must contain no more than " + maxItems + " item(s)", val, maxItems)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (unique && !isUnique(list)) { violations = - add(violations, NativeViolations.newViolation(UNIQUE_SITE, null, null, val, true)); - if (failFast) return done(violations); + RuleBase.add( + violations, NativeViolations.newViolation(UNIQUE_SITE, null, null, val, true)); + if (failFast) return base.done(violations); } - return done(violations); + return base.done(violations); } /** @@ -208,20 +207,4 @@ private static boolean isUnique(List list) { return true; } - private static List add( - @Nullable List violations, RuleViolation.Builder v) { - if (violations == null) { - violations = new ArrayList<>(2); - } - violations.add(v); - return violations; - } - - private List done(@Nullable List violations) { - if (violations == null || violations.isEmpty()) { - return RuleViolation.NO_VIOLATIONS; - } - return FieldPathUtils.updatePaths( - violations, base.getFieldPathElement(), base.getRulePrefixElements()); - } } diff --git a/src/main/java/build/buf/protovalidate/rules/RuleBase.java b/src/main/java/build/buf/protovalidate/rules/RuleBase.java index b128a89f..499e5d4d 100644 --- a/src/main/java/build/buf/protovalidate/rules/RuleBase.java +++ b/src/main/java/build/buf/protovalidate/rules/RuleBase.java @@ -15,12 +15,15 @@ package build.buf.protovalidate.rules; import build.buf.protovalidate.FieldPathUtils; +import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.ValueEvaluator; import build.buf.validate.FieldPath; import build.buf.validate.FieldPathElement; import com.google.protobuf.Descriptors.FieldDescriptor; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.Function; import org.jspecify.annotations.Nullable; /** @@ -81,4 +84,52 @@ List getRulePrefixElements() { } return rulePrefix.getElementsList(); } + + // --- Shared violation-list helpers --- + // + // Every native evaluator uses the same lazy null → ArrayList growth pattern and the same + // tail-call to FieldPathUtils.updatePaths. These live here so each evaluator doesn't + // re-implement them. + + /** + * Lazily appends {@code v} to {@code violations}, allocating an {@link ArrayList} only on the + * first append. + */ + static List add( + @Nullable List violations, RuleViolation.Builder v) { + if (violations == null) { + violations = new ArrayList<>(2); + } + violations.add(v); + return violations; + } + + /** + * Finalizes a violation list: returns the empty constant when there's nothing to report, + * otherwise prepends this base's field-path element and rule-prefix elements. + */ + List done(@Nullable List violations) { + if (violations == null || violations.isEmpty()) { + return RuleViolation.NO_VIOLATIONS; + } + return FieldPathUtils.updatePaths(violations, fieldPathElement, getRulePrefixElements()); + } + + /** Renders a list as {@code "[a, b, c]"} using {@code toString} on each element. */ + static String formatList(List vals) { + return formatList(vals, Object::toString); + } + + /** Renders a list as {@code "[a, b, c]"} using {@code formatter} on each element. */ + static String formatList(List vals, Function formatter) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < vals.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(formatter.apply(vals.get(i))); + } + sb.append("]"); + return sb.toString(); + } } diff --git a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java index a6321ac8..1f52a406 100644 --- a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java @@ -16,7 +16,6 @@ import build.buf.protovalidate.CustomOverload; import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.FieldPathUtils; import build.buf.protovalidate.RuleViolation; import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; @@ -24,7 +23,6 @@ import build.buf.validate.StringRules; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.re2j.Pattern; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import org.jspecify.annotations.Nullable; @@ -573,7 +571,7 @@ public List evaluate(Value val, boolean failFast) { long runeCount = strVal.codePointCount(0, strVal.length()); violations = applyLength(violations, val, runeCount, failFast); if (failFast && violations != null) { - return done(violations); + return base.done(violations); } } @@ -581,22 +579,22 @@ public List evaluate(Value val, boolean failFast) { long byteCount = utf8ByteLength(strVal); violations = applyByteLength(violations, val, byteCount, failFast); if (failFast && violations != null) { - return done(violations); + return base.done(violations); } } if (constVal != null && !strVal.equals(constVal)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( CONST_SITE, null, "must equal `" + constVal + "`", val, constVal)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (pattern != null && !pattern.matches(strVal)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( PATTERN_SITE, @@ -604,30 +602,30 @@ public List evaluate(Value val, boolean failFast) { "does not match regex pattern `" + patternStr + "`", val, patternStr)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (prefix != null && !strVal.startsWith(prefix)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( PREFIX_SITE, null, "does not have prefix `" + prefix + "`", val, prefix)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (suffix != null && !strVal.endsWith(suffix)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( SUFFIX_SITE, null, "does not have suffix `" + suffix + "`", val, suffix)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (contains != null && !strVal.contains(contains)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( CONTAINS_SITE, @@ -635,12 +633,12 @@ public List evaluate(Value val, boolean failFast) { "does not contain substring `" + contains + "`", val, contains)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (notContains != null && strVal.contains(notContains)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( NOT_CONTAINS_SITE, @@ -648,46 +646,46 @@ public List evaluate(Value val, boolean failFast) { "value contains substring `" + notContains + "`", val, notContains)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (!inVals.isEmpty() && !inVals.contains(strVal)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( - IN_SITE, null, "must be in list " + formatList(inVals), val, strVal)); - if (failFast) return done(violations); + IN_SITE, null, "must be in list " + RuleBase.formatList(inVals), val, strVal)); + if (failFast) return base.done(violations); } if (!notInVals.isEmpty() && notInVals.contains(strVal)) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( NOT_IN_SITE, null, - "must not be in list " + formatList(notInVals), + "must not be in list " + RuleBase.formatList(notInVals), val, strVal)); - if (failFast) return done(violations); + if (failFast) return base.done(violations); } if (wellKnown != null) { RuleViolation.Builder wkv = checkWellKnown(strVal, val); if (wkv != null) { - violations = add(violations, wkv); - if (failFast) return done(violations); + violations = RuleBase.add(violations, wkv); + if (failFast) return base.done(violations); } } else if (knownRegex != KnownRegex.KNOWN_REGEX_UNSPECIFIED) { RuleViolation.Builder krv = checkKnownRegex(strVal, val); if (krv != null) { - violations = add(violations, krv); - if (failFast) return done(violations); + violations = RuleBase.add(violations, krv); + if (failFast) return base.done(violations); } } - return done(violations); + return base.done(violations); } // --- Length checks --- @@ -699,7 +697,7 @@ public List evaluate(Value val, boolean failFast) { boolean failFast) { if (exactLen != null && runeCount != exactLen) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( LEN_SITE, null, "must be " + exactLen + " characters", val, exactLen)); @@ -707,7 +705,7 @@ public List evaluate(Value val, boolean failFast) { } if (minLen != null && runeCount < minLen) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MIN_LEN_SITE, null, "must be at least " + minLen + " characters", val, minLen)); @@ -715,7 +713,7 @@ public List evaluate(Value val, boolean failFast) { } if (maxLen != null && runeCount > maxLen) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MAX_LEN_SITE, null, "must be at most " + maxLen + " characters", val, maxLen)); @@ -731,7 +729,7 @@ public List evaluate(Value val, boolean failFast) { boolean failFast) { if (exactBytes != null && byteCount != exactBytes) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( LEN_BYTES_SITE, null, "must be " + exactBytes + " bytes", val, exactBytes)); @@ -739,7 +737,7 @@ public List evaluate(Value val, boolean failFast) { } if (minBytes != null && byteCount < minBytes) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MIN_BYTES_SITE, null, "must be at least " + minBytes + " bytes", val, minBytes)); @@ -747,7 +745,7 @@ public List evaluate(Value val, boolean failFast) { } if (maxBytes != null && byteCount > maxBytes) { violations = - add( + RuleBase.add( violations, NativeViolations.newViolation( MAX_BYTES_SITE, null, "must be at most " + maxBytes + " bytes", val, maxBytes)); @@ -835,32 +833,4 @@ private static long utf8ByteLength(String s) { return count; } - private static String formatList(List vals) { - StringBuilder sb = new StringBuilder("["); - for (int i = 0; i < vals.size(); i++) { - if (i > 0) { - sb.append(", "); - } - sb.append(vals.get(i)); - } - sb.append("]"); - return sb.toString(); - } - - private static List add( - @Nullable List violations, RuleViolation.Builder v) { - if (violations == null) { - violations = new ArrayList<>(2); - } - violations.add(v); - return violations; - } - - private List done(@Nullable List violations) { - if (violations == null || violations.isEmpty()) { - return RuleViolation.NO_VIOLATIONS; - } - return FieldPathUtils.updatePaths( - violations, base.getFieldPathElement(), base.getRulePrefixElements()); - } } From 0a6faec9684b5c606db686252bd005ed9894e45e Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 1 May 2026 16:29:54 -0400 Subject: [PATCH 16/31] Add unit tests for native rule coverage gaps Adds five test classes covering contracts the conformance suite doesn't exercise directly: - FailFastTest: native evaluators must short-circuit after the first violation when failFast=true. One fixture per evaluator kind with two rules that both fail. - NotInRulesTest: pins rule_id and message text for not_in on int32, uint32, string, bytes, and enum. - ResidualClearingTest: asserts exactly one violation (not two) per rule type, proving the dispatcher's clone-and-clear contract for each kind. - WellKnownRegexTest: HTTP header_name/header_value, strict + loose modes, and the empty-header-name special case. - WrappedValueEvaluatorTest: absent wrapper field, present wrapper with default inner value, present wrapper with violating inner value. Adds the validationtest.proto fixtures these tests need (multi-rule fixtures, NotIn fixtures, HttpHeader fixtures, wrapper fixtures), pulling in google/protobuf/wrappers.proto. Parameterizes NativeRulesParityTest with @MethodSource so each fixture produces a discrete pass/fail in the JUnit report. --- .../protovalidate/NativeRulesParityTest.java | 112 +++++++++++------- .../buf/protovalidate/rules/FailFastTest.java | 75 ++++++++++++ .../protovalidate/rules/NotInRulesTest.java | 90 ++++++++++++++ .../rules/ResidualClearingTest.java | 89 ++++++++++++++ .../rules/WellKnownRegexTest.java | 100 ++++++++++++++++ .../rules/WrappedValueEvaluatorTest.java | 85 +++++++++++++ .../proto/validationtest/validationtest.proto | 106 +++++++++++++++++ 7 files changed, 611 insertions(+), 46 deletions(-) create mode 100644 src/test/java/build/buf/protovalidate/rules/FailFastTest.java create mode 100644 src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java create mode 100644 src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java create mode 100644 src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java create mode 100644 src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index 313d6432..cdebd365 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -40,7 +40,10 @@ import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; -import org.junit.jupiter.api.Test; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Parity test: runs a representative slice of conformance fixtures through both modes @@ -65,54 +68,71 @@ class NativeRulesParityTest { .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); - @Test - void parityAcrossRuleTypes() throws ValidationException { - // Each fixture exercises a different rule type. Order: - // bool, enum, bytes, numeric (signed + unsigned), string (scalar + format), repeated, map. - List fixtures = - Arrays.asList( - // Bool — fails const=true. - BoolConstTrue.newBuilder().build(), - // Enum defined_only — fails (2147483647 not in defined values). - EnumDefined.newBuilder().setValValue(2147483647).build(), - // Bytes contains — pass case. - BytesContains.newBuilder().setVal(ByteString.copyFromUtf8("candy bars")).build(), - // Bytes in — pass case (empty matches none of the in list, but the field is - // implicit-presence so the rule is skipped on empty value). - BytesIn.newBuilder().setVal(ByteString.copyFromUtf8("bar")).build(), - // Fixed32 (unsigned) lt — fails (val=5, lt=5). - Fixed32LT.newBuilder().setVal(5).build(), - // Int32 in — fails (4 not in list). - Int32In.newBuilder().setVal(4).build(), - // SFixed64 in — fails (5 not in list). - SFixed64In.newBuilder().setVal(5).build(), - // String prefix — pass case. - StringPrefix.newBuilder().setVal("foo").build(), - // String contains — pass case. - StringContains.newBuilder().setVal("foobar").build(), - // String length with code points — emoji counts as 1 each. - StringLen.newBuilder().setVal("😅😄👾").build(), - // Repeated exact — fails (2 items, exact=3). - RepeatedExact.newBuilder().addAllVal(Arrays.asList(1, 2)).build(), - // Repeated unique — fails (duplicate "foo"). + /** + * Each entry exercises a different rule type. JUnit reports per-fixture pass/fail, so adding a + * fixture and watching CI is a single-line change. Order: + * bool, enum, bytes, numeric (signed + unsigned), string (scalar + format), repeated, map. + */ + static Stream fixtures() { + return Stream.of( + // Bool — fails const=true. + Arguments.of("BoolConstTrue", BoolConstTrue.newBuilder().build()), + // Enum defined_only — fails (2147483647 not in defined values). + Arguments.of( + "EnumDefined.undefined", EnumDefined.newBuilder().setValValue(2147483647).build()), + // Bytes contains — pass case. + Arguments.of( + "BytesContains.pass", + BytesContains.newBuilder().setVal(ByteString.copyFromUtf8("candy bars")).build()), + // Bytes in — pass case (empty matches none of the in list, but the field is + // implicit-presence so the rule is skipped on empty value). + Arguments.of( + "BytesIn.pass", BytesIn.newBuilder().setVal(ByteString.copyFromUtf8("bar")).build()), + // Fixed32 (unsigned) lt — fails (val=5, lt=5). + Arguments.of("Fixed32LT.fail", Fixed32LT.newBuilder().setVal(5).build()), + // Int32 in — fails (4 not in list). + Arguments.of("Int32In.fail", Int32In.newBuilder().setVal(4).build()), + // SFixed64 in — fails (5 not in list). + Arguments.of("SFixed64In.fail", SFixed64In.newBuilder().setVal(5).build()), + // String prefix — pass case. + Arguments.of("StringPrefix.pass", StringPrefix.newBuilder().setVal("foo").build()), + // String contains — pass case. + Arguments.of( + "StringContains.pass", StringContains.newBuilder().setVal("foobar").build()), + // String length with code points — emoji counts as 1 each. + Arguments.of("StringLen.emoji", StringLen.newBuilder().setVal("😅😄👾").build()), + // Repeated exact — fails (2 items, exact=3). + Arguments.of( + "RepeatedExact.fail", + RepeatedExact.newBuilder().addAllVal(Arrays.asList(1, 2)).build()), + // Repeated unique — fails (duplicate "foo"). + Arguments.of( + "RepeatedUnique.fail", RepeatedUnique.newBuilder() .addAllVal(Arrays.asList("foo", "bar", "foo", "baz")) - .build(), - // Repeated enum in — fails. - RepeatedEnumIn.newBuilder().addVal(AnEnum.AN_ENUM_X).build(), - // Wrapper-typed double (google.protobuf.DoubleValue) — exercises the native - // wrapper-unwrap path. Empty wrapper = value 0.0, fails the rule. - WrapperDouble.newBuilder().setVal(DoubleValue.newBuilder().build()).build(), - // KitchenSinkMessage with empty inner ComplexTestMsg — many violations. - KitchenSinkMessage.newBuilder().setVal(ComplexTestMsg.newBuilder().build()).build()); + .build()), + // Repeated enum in — fails. + Arguments.of( + "RepeatedEnumIn.fail", RepeatedEnumIn.newBuilder().addVal(AnEnum.AN_ENUM_X).build()), + // Wrapper-typed double (google.protobuf.DoubleValue) — exercises the native + // wrapper-unwrap path. Empty wrapper = value 0.0, fails the rule. + Arguments.of( + "WrapperDouble.emptyInner", + WrapperDouble.newBuilder().setVal(DoubleValue.newBuilder().build()).build()), + // KitchenSinkMessage with empty inner ComplexTestMsg — many violations. + Arguments.of( + "KitchenSinkMessage.emptyInner", + KitchenSinkMessage.newBuilder().setVal(ComplexTestMsg.newBuilder().build()).build())); + } - for (Message msg : fixtures) { - ValidationResult nativeResult = nativeValidator.validate(msg); - ValidationResult celResult = celValidator.validate(msg); - assertThat(toProtoList(nativeResult)) - .as("toProto() parity failed for %s", msg.getDescriptorForType().getFullName()) - .isEqualTo(toProtoList(celResult)); - } + @ParameterizedTest(name = "{0}") + @MethodSource("fixtures") + void parityForFixture(String name, Message msg) throws ValidationException { + ValidationResult nativeResult = nativeValidator.validate(msg); + ValidationResult celResult = celValidator.validate(msg); + assertThat(toProtoList(nativeResult)) + .as("toProto() parity for %s", name) + .isEqualTo(toProtoList(celResult)); } private static List toProtoList(ValidationResult result) { diff --git a/src/test/java/build/buf/protovalidate/rules/FailFastTest.java b/src/test/java/build/buf/protovalidate/rules/FailFastTest.java new file mode 100644 index 00000000..99f5c812 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/FailFastTest.java @@ -0,0 +1,75 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.BytesMultiRule; +import com.example.noimports.validationtest.Int32MultiRule; +import com.example.noimports.validationtest.StringMultiRule; +import com.google.protobuf.ByteString; +import com.google.protobuf.Message; +import org.junit.jupiter.api.Test; + +/** + * failFast tests for the native rule evaluators. Each fixture is constructed so the input + * violates two rules. Without failFast, both violations are reported; with failFast=true, the + * validator must short-circuit after the first. + */ +class FailFastTest { + + private static Validator validator(boolean failFast) { + Config config = Config.newBuilder().setEnableNativeRules(true).setFailFast(failFast).build(); + return ValidatorFactory.newBuilder().withConfig(config).build(); + } + + @Test + void stringEvaluator_failFastSkipsLaterRules() throws ValidationException { + // "ab" — fails min_len=4, would also fail pattern .*[0-9].* + StringMultiRule msg = StringMultiRule.newBuilder().setVal("ab").build(); + assertTwoViolationsWithoutFailFastOneWith(msg); + } + + @Test + void numericEvaluator_failFastSkipsLaterRules() throws ValidationException { + // val=0: fails const=5 and fails gt=10 + Int32MultiRule msg = Int32MultiRule.newBuilder().setVal(0).build(); + assertTwoViolationsWithoutFailFastOneWith(msg); + } + + @Test + void bytesEvaluator_failFastSkipsLaterRules() throws ValidationException { + // 1-byte value — fails min_len=4 AND fails ipv4 size requirement. + BytesMultiRule msg = + BytesMultiRule.newBuilder().setVal(ByteString.copyFrom(new byte[] {0x01})).build(); + assertTwoViolationsWithoutFailFastOneWith(msg); + } + + private void assertTwoViolationsWithoutFailFastOneWith(Message msg) throws ValidationException { + ValidationResult full = validator(false).validate(msg); + ValidationResult fast = validator(true).validate(msg); + assertThat(full.getViolations()) + .as("without failFast, both violations should be reported") + .hasSize(2); + assertThat(fast.getViolations()) + .as("with failFast, only the first violation should be reported") + .hasSize(1); + } +} diff --git a/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java b/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java new file mode 100644 index 00000000..17c9b595 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java @@ -0,0 +1,90 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.BytesNotIn; +import com.example.noimports.validationtest.EnumNotIn; +import com.example.noimports.validationtest.ExampleColor; +import com.example.noimports.validationtest.Int32NotIn; +import com.example.noimports.validationtest.StringNotIn; +import com.example.noimports.validationtest.Uint32NotIn; +import com.google.protobuf.ByteString; +import org.junit.jupiter.api.Test; + +/** + * Targeted not_in coverage for each native evaluator. The conformance suite covers behavior; what + * these tests pin down is the rule_id and message text the native path produces, since those are + * the contract observable from {@code Violation.toProto()}. + */ +class NotInRulesTest { + + private final Validator validator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void int32NotIn() throws ValidationException { + Int32NotIn msg = Int32NotIn.newBuilder().setVal(2).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("int32.not_in"); + assertThat(result.getViolations().get(0).toProto().getMessage()) + .isEqualTo("must not be in list [1, 2, 3]"); + } + + @Test + void uint32NotIn() throws ValidationException { + Uint32NotIn msg = Uint32NotIn.newBuilder().setVal(1).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("uint32.not_in"); + } + + @Test + void stringNotIn() throws ValidationException { + StringNotIn msg = StringNotIn.newBuilder().setVal("foo").build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("string.not_in"); + assertThat(result.getViolations().get(0).toProto().getMessage()) + .isEqualTo("must not be in list [foo, bar]"); + } + + @Test + void bytesNotIn() throws ValidationException { + BytesNotIn msg = BytesNotIn.newBuilder().setVal(ByteString.copyFromUtf8("AA")).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("bytes.not_in"); + } + + @Test + void enumNotIn() throws ValidationException { + EnumNotIn msg = EnumNotIn.newBuilder().setVal(ExampleColor.EXAMPLE_COLOR_RED).build(); + ValidationResult result = validator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + assertThat(result.getViolations().get(0).toProto().getRuleId()).isEqualTo("enum.not_in"); + assertThat(result.getViolations().get(0).toProto().getMessage()) + .isEqualTo("must not be in list [1, 2]"); + } +} diff --git a/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java b/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java new file mode 100644 index 00000000..9bf3b324 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java @@ -0,0 +1,89 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.ExampleBoolConst; +import com.example.noimports.validationtest.ExampleBytesConst; +import com.example.noimports.validationtest.ExampleEnumConst; +import com.example.noimports.validationtest.ExampleInt32Const; +import com.example.noimports.validationtest.ExampleMapMinMax; +import com.example.noimports.validationtest.ExampleRepeatedMinMax; +import com.example.noimports.validationtest.ExampleStringConst; +import com.google.protobuf.Message; +import org.junit.jupiter.api.Test; + +/** + * Residual-clearing contract tests. When a native evaluator handles a rule, the dispatcher must + * clear that rule on the residual {@code FieldRules} so {@code RuleCache} doesn't compile a CEL + * program that fires a duplicate violation. Each test below uses a fixture that fails exactly one + * native rule and asserts that the validator produces exactly one violation, not two. + */ +class ResidualClearingTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void boolConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleBoolConst.newBuilder().setFlag(false).build()); + } + + @Test + void int32ConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleInt32Const.newBuilder().setVal(0).build()); + } + + @Test + void enumConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleEnumConst.newBuilder().build()); + } + + @Test + void bytesConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleBytesConst.newBuilder().build()); + } + + @Test + void stringConstFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleStringConst.newBuilder().setVal("nope").build()); + } + + @Test + void repeatedMinItemsFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleRepeatedMinMax.newBuilder().build()); + } + + @Test + void mapMinPairsFiresOnce() throws ValidationException { + assertExactlyOneViolation(ExampleMapMinMax.newBuilder().build()); + } + + private void assertExactlyOneViolation(Message msg) throws ValidationException { + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()) + .as("native dispatcher must clear the rule from the residual; expected exactly one " + + "violation but got: %s", result.getViolations()) + .hasSize(1); + } +} diff --git a/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java b/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java new file mode 100644 index 00000000..0746a809 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java @@ -0,0 +1,100 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.HttpHeaderName; +import com.example.noimports.validationtest.HttpHeaderNameLoose; +import com.example.noimports.validationtest.HttpHeaderValue; +import org.junit.jupiter.api.Test; + +/** + * Tests for the {@code well_known_regex} oneof case in {@link StringRulesEvaluator}: HTTP header + * name and value, in both strict and loose modes, plus the empty-header-name special case. + */ +class WellKnownRegexTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void headerName_strict_passesValidName() throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal("X-Request-Id").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } + + @Test + void headerName_strict_failsInvalidName() throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal("not a header").build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation v = result.getViolations().get(0).toProto(); + assertThat(v.getRuleId()).isEqualTo("string.well_known_regex.header_name"); + assertThat(v.getMessage()).isEqualTo("must be a valid HTTP header name"); + } + + @Test + void headerName_emptyValue_firesEmptyVariant() throws ValidationException { + // Empty header name is a separate rule id with its own message. + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal("").build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation v = result.getViolations().get(0).toProto(); + assertThat(v.getRuleId()).isEqualTo("string.well_known_regex.header_name_empty"); + assertThat(v.getMessage()) + .isEqualTo("value is empty, which is not a valid HTTP header name"); + } + + @Test + void headerName_loose_acceptsValueStrictWouldReject() throws ValidationException { + // Strict regex would reject spaces; loose just forbids null/CR/LF. + HttpHeaderNameLoose msg = + HttpHeaderNameLoose.newBuilder().setVal("any header with spaces").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } + + @Test + void headerValue_strict_passesValidValue() throws ValidationException { + HttpHeaderValue msg = HttpHeaderValue.newBuilder().setVal("text/plain").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } + + @Test + void headerValue_strict_failsControlChar() throws ValidationException { + // 0x01 is in the forbidden range for strict header values. + HttpHeaderValue msg = HttpHeaderValue.newBuilder().setVal("").build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation v = result.getViolations().get(0).toProto(); + assertThat(v.getRuleId()).isEqualTo("string.well_known_regex.header_value"); + assertThat(v.getMessage()).isEqualTo("must be a valid HTTP header value"); + } + + @Test + void headerValue_emptyValueIsValid() throws ValidationException { + // Header value pattern is '*' (zero-or-more), so empty is allowed under strict mode and + // there is no header_value_empty variant. + HttpHeaderValue msg = HttpHeaderValue.newBuilder().setVal("").build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } +} diff --git a/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java new file mode 100644 index 00000000..4ad68e77 --- /dev/null +++ b/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java @@ -0,0 +1,85 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// 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 build.buf.protovalidate.rules; + +import static org.assertj.core.api.Assertions.assertThat; + +import build.buf.protovalidate.Config; +import build.buf.protovalidate.ValidationResult; +import build.buf.protovalidate.Validator; +import build.buf.protovalidate.ValidatorFactory; +import build.buf.protovalidate.exceptions.ValidationException; +import com.example.noimports.validationtest.Int64WrapperConst; +import com.example.noimports.validationtest.StringWrapperLen; +import com.google.protobuf.Int64Value; +import com.google.protobuf.StringValue; +import org.junit.jupiter.api.Test; + +/** + * Targeted tests for {@link WrappedValueEvaluator}. Mirrors the wrapper-unwrap path that the + * native dispatcher takes for {@code google.protobuf.*Value} fields. The conformance suite covers + * one wrapper kind (DoubleValue) via parity tests; these cover the unwrap invariant directly. + */ +class WrappedValueEvaluatorTest { + + private final Validator nativeValidator = + ValidatorFactory.newBuilder() + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .build(); + + @Test + void absentWrapperFieldProducesNoViolation() throws ValidationException { + // Field unset (proto3 message-typed field absent): there is no value to validate, so the + // wrapped scalar evaluator should not be invoked. WrappedValueEvaluator's contract returns + // NO_VIOLATIONS in that case. + Int64WrapperConst msg = Int64WrapperConst.newBuilder().build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + void presentWrapperWithDefaultInnerValueFiresRule() throws ValidationException { + // Wrapper present with default inner (0). Rule is const=5 — 0 != 5 so the rule fires. + // This proves the unwrap reaches the inner field rather than seeing the wrapper Message. + Int64WrapperConst msg = + Int64WrapperConst.newBuilder().setVal(Int64Value.newBuilder().build()).build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("int64.const"); + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("val"); + } + + @Test + void presentWrapperWithViolatingValueProducesExpectedShape() throws ValidationException { + StringWrapperLen msg = + StringWrapperLen.newBuilder().setVal(StringValue.newBuilder().setValue("ab").build()).build(); + ValidationResult result = nativeValidator.validate(msg); + assertThat(result.getViolations()).hasSize(1); + build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); + assertThat(proto.getRuleId()).isEqualTo("string.min_len"); + // Field path points at the wrapper-typed field, not the synthetic inner "value". + assertThat(proto.getField().getElements(0).getFieldName()).isEqualTo("val"); + } + + @Test + void presentWrapperWithPassingValueProducesNoViolation() throws ValidationException { + Int64WrapperConst msg = + Int64WrapperConst.newBuilder() + .setVal(Int64Value.newBuilder().setValue(5).build()) + .build(); + assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); + } +} diff --git a/src/test/resources/proto/validationtest/validationtest.proto b/src/test/resources/proto/validationtest/validationtest.proto index 9fd0d56d..a49c8fc6 100644 --- a/src/test/resources/proto/validationtest/validationtest.proto +++ b/src/test/resources/proto/validationtest/validationtest.proto @@ -17,6 +17,7 @@ syntax = "proto3"; package validationtest; import "buf/validate/validate.proto"; +import "google/protobuf/wrappers.proto"; import "validationtest/import_test.proto"; message ExampleFieldRules { @@ -273,3 +274,108 @@ message FloatDoubleNaNNegZero { float fneg_zero = 3 [(buf.validate.field).float.const = -0.0]; double dneg_zero = 4 [(buf.validate.field).double.const = -0.0]; } + +// Multi-rule fixtures used to exercise failFast and residual-clearing semantics. +// +// Each field has two scalar rules (or a count + element rule) so a value that violates the +// first rule will also violate the second. With failFast=true the validator should report only +// one violation; with failFast=false (the default), both should be reported. + +// String with min_len=4 and a "must contain digit" pattern. +message StringMultiRule { + string val = 1 [(buf.validate.field).string = { + min_len: 4 + pattern: ".*[0-9].*" + }]; +} + +// Int with gt=10 and in=[5,15]. Default 0 violates both. +message Int32MultiRule { + int32 val = 1 [(buf.validate.field).int32 = { + gt: 10 + in: [ + 5, + 15 + ] + }]; +} + +// Bytes with min_len=4 and ipv4=true. Empty value violates both. +message BytesMultiRule { + bytes val = 1 [(buf.validate.field).bytes = { + min_len: 4 + ipv4: true + }]; +} + +// not_in fixtures, one per kind, used by NotInRulesTest. +message Int32NotIn { + int32 val = 1 [(buf.validate.field).int32 = { + not_in: [ + 1, + 2, + 3 + ] + }]; +} + +message Uint32NotIn { + uint32 val = 1 [(buf.validate.field).uint32 = { + not_in: [ + 1, + 2 + ] + }]; +} + +message StringNotIn { + string val = 1 [(buf.validate.field).string = { + not_in: [ + "foo", + "bar" + ] + }]; +} + +message BytesNotIn { + bytes val = 1 [(buf.validate.field).bytes = { + not_in: [ + "AA", + "BB" + ] + }]; +} + +message EnumNotIn { + ExampleColor val = 1 [(buf.validate.field).enum = { + not_in: [ + 1, + 2 + ] + }]; +} + +// well_known_regex fixtures. +message HttpHeaderName { + string val = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_NAME]; +} + +message HttpHeaderNameLoose { + string val = 1 [(buf.validate.field).string = { + well_known_regex: KNOWN_REGEX_HTTP_HEADER_NAME + strict: false + }]; +} + +message HttpHeaderValue { + string val = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; +} + +// google.protobuf.*Value wrapper fixtures for WrappedValueEvaluator coverage. +message Int64WrapperConst { + google.protobuf.Int64Value val = 1 [(buf.validate.field).int64.const = 5]; +} + +message StringWrapperLen { + google.protobuf.StringValue val = 1 [(buf.validate.field).string.min_len = 3]; +} From ad669ef9a70f04b040dc5b3eca0510ebd4d56f7e Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 1 May 2026 16:30:36 -0400 Subject: [PATCH 17/31] Add benchmarks for previously uncovered native rules Adds 16 single-rule fixtures and matching @Benchmark methods so each rule that the native path now handles has an isolated CEL-vs-native A/B (the existing BenchComplexSchema mixed too many rules into one timing). Covers the gaps identified in the second-pass review: - string: const, len, min_len (isolated), prefix, contains, in - bytes: const, in - numeric (non-int32): int64 const+in, uint32 in, double in - enum: const, not_in - repeated.unique: string, int32 Each fixture's value satisfies its rule so the benchmark measures the happy path. The `enableNativeRules` @Param continues to A/B native vs CEL. --- .../benchmarks/BenchFixtures.java | 91 +++++++++++ .../benchmarks/ValidationBenchmark.java | 149 ++++++++++++++++++ .../src/jmh/proto/bench/v1/native_bench.proto | 105 ++++++++++++ 3 files changed, 345 insertions(+) diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java index 0b8c9c33..95dd914f 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/BenchFixtures.java @@ -15,17 +15,33 @@ package build.buf.protovalidate.benchmarks; import build.buf.protovalidate.benchmarks.gen.BenchBoolConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesIn; import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchDoubleIn; import build.buf.protovalidate.benchmarks.gen.BenchEnum; +import build.buf.protovalidate.benchmarks.gen.BenchEnumConst; +import build.buf.protovalidate.benchmarks.gen.BenchEnumNotIn; import build.buf.protovalidate.benchmarks.gen.BenchEnumRules; import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.benchmarks.gen.BenchInt64Const; +import build.buf.protovalidate.benchmarks.gen.BenchInt64In; import build.buf.protovalidate.benchmarks.gen.BenchMap; import build.buf.protovalidate.benchmarks.gen.BenchPhaseEnum; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedInt32Unique; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedMessage; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalar; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalarUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedStringUnique; import build.buf.protovalidate.benchmarks.gen.BenchScalar; +import build.buf.protovalidate.benchmarks.gen.BenchStringConst; +import build.buf.protovalidate.benchmarks.gen.BenchStringContains; +import build.buf.protovalidate.benchmarks.gen.BenchStringIn; +import build.buf.protovalidate.benchmarks.gen.BenchStringLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringMinLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringPrefix; +import build.buf.protovalidate.benchmarks.gen.BenchUint32In; import build.buf.protovalidate.benchmarks.gen.MultiRule; import build.buf.protovalidate.benchmarks.gen.StringMatching; import build.buf.protovalidate.benchmarks.gen.TestByteMatching; @@ -213,4 +229,79 @@ static BenchBoolConst benchBoolConst() { static BenchEnumRules benchEnumRules() { return BenchEnumRules.newBuilder().setVal(BenchPhaseEnum.BENCH_PHASE_ENUM_TWO).build(); } + + // --- Single-rule fixtures for previously unbenchmarked rules --- + // Each fixture's value satisfies its rule — benchmarks measure the happy path. + + static BenchStringConst benchStringConst() { + return BenchStringConst.newBuilder().setS("hello").build(); + } + + static BenchStringLen benchStringLen() { + return BenchStringLen.newBuilder().setS("hello").build(); + } + + static BenchStringMinLen benchStringMinLen() { + return BenchStringMinLen.newBuilder().setS("x").build(); + } + + static BenchStringPrefix benchStringPrefix() { + return BenchStringPrefix.newBuilder().setS("user-alice").build(); + } + + static BenchStringContains benchStringContains() { + return BenchStringContains.newBuilder().setS("alice@example.com").build(); + } + + static BenchStringIn benchStringIn() { + return BenchStringIn.newBuilder().setS("bar").build(); + } + + static BenchBytesConst benchBytesConst() { + return BenchBytesConst.newBuilder().setB(ByteString.copyFromUtf8("abc")).build(); + } + + static BenchBytesIn benchBytesIn() { + return BenchBytesIn.newBuilder().setB(ByteString.copyFromUtf8("bar")).build(); + } + + static BenchInt64Const benchInt64Const() { + return BenchInt64Const.newBuilder().setV(42L).build(); + } + + static BenchInt64In benchInt64In() { + return BenchInt64In.newBuilder().setV(2L).build(); + } + + static BenchUint32In benchUint32In() { + return BenchUint32In.newBuilder().setV(2).build(); + } + + static BenchDoubleIn benchDoubleIn() { + return BenchDoubleIn.newBuilder().setV(2.0).build(); + } + + static BenchEnumConst benchEnumConst() { + return BenchEnumConst.newBuilder().setVal(BenchPhaseEnum.BENCH_PHASE_ENUM_ONE).build(); + } + + static BenchEnumNotIn benchEnumNotIn() { + return BenchEnumNotIn.newBuilder().setVal(BenchPhaseEnum.BENCH_PHASE_ENUM_ONE).build(); + } + + static BenchRepeatedStringUnique benchRepeatedStringUnique() { + BenchRepeatedStringUnique.Builder b = BenchRepeatedStringUnique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX("entry-" + i); + } + return b.build(); + } + + static BenchRepeatedInt32Unique benchRepeatedInt32Unique() { + BenchRepeatedInt32Unique.Builder b = BenchRepeatedInt32Unique.newBuilder(); + for (int i = 1; i <= 8; i++) { + b.addX(i); + } + return b.build(); + } } diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index a4a87946..9476c393 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -18,15 +18,31 @@ import build.buf.protovalidate.Validator; import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.benchmarks.gen.BenchBoolConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesConst; +import build.buf.protovalidate.benchmarks.gen.BenchBytesIn; import build.buf.protovalidate.benchmarks.gen.BenchComplexSchema; +import build.buf.protovalidate.benchmarks.gen.BenchDoubleIn; +import build.buf.protovalidate.benchmarks.gen.BenchEnumConst; +import build.buf.protovalidate.benchmarks.gen.BenchEnumNotIn; import build.buf.protovalidate.benchmarks.gen.BenchEnumRules; import build.buf.protovalidate.benchmarks.gen.BenchGT; +import build.buf.protovalidate.benchmarks.gen.BenchInt64Const; +import build.buf.protovalidate.benchmarks.gen.BenchInt64In; import build.buf.protovalidate.benchmarks.gen.BenchMap; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedBytesUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedInt32Unique; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedMessage; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalar; import build.buf.protovalidate.benchmarks.gen.BenchRepeatedScalarUnique; +import build.buf.protovalidate.benchmarks.gen.BenchRepeatedStringUnique; import build.buf.protovalidate.benchmarks.gen.BenchScalar; +import build.buf.protovalidate.benchmarks.gen.BenchStringConst; +import build.buf.protovalidate.benchmarks.gen.BenchStringContains; +import build.buf.protovalidate.benchmarks.gen.BenchStringIn; +import build.buf.protovalidate.benchmarks.gen.BenchStringLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringMinLen; +import build.buf.protovalidate.benchmarks.gen.BenchStringPrefix; +import build.buf.protovalidate.benchmarks.gen.BenchUint32In; import build.buf.protovalidate.benchmarks.gen.ManyUnruledFieldsMessage; import build.buf.protovalidate.benchmarks.gen.MultiRule; import build.buf.protovalidate.benchmarks.gen.RegexPatternMessage; @@ -93,6 +109,24 @@ public class ValidationBenchmark { private BenchBoolConst benchBoolConst; private BenchEnumRules benchEnumRules; + // Single-rule fixtures filling earlier coverage gaps. + private BenchStringConst benchStringConst; + private BenchStringLen benchStringLen; + private BenchStringMinLen benchStringMinLen; + private BenchStringPrefix benchStringPrefix; + private BenchStringContains benchStringContains; + private BenchStringIn benchStringIn; + private BenchBytesConst benchBytesConst; + private BenchBytesIn benchBytesIn; + private BenchInt64Const benchInt64Const; + private BenchInt64In benchInt64In; + private BenchUint32In benchUint32In; + private BenchDoubleIn benchDoubleIn; + private BenchEnumConst benchEnumConst; + private BenchEnumNotIn benchEnumNotIn; + private BenchRepeatedStringUnique benchRepeatedStringUnique; + private BenchRepeatedInt32Unique benchRepeatedInt32Unique; + @Setup public void setup() throws ValidationException { Config config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); @@ -138,6 +172,23 @@ public void setup() throws ValidationException { benchBoolConst = BenchFixtures.benchBoolConst(); benchEnumRules = BenchFixtures.benchEnumRules(); + benchStringConst = BenchFixtures.benchStringConst(); + benchStringLen = BenchFixtures.benchStringLen(); + benchStringMinLen = BenchFixtures.benchStringMinLen(); + benchStringPrefix = BenchFixtures.benchStringPrefix(); + benchStringContains = BenchFixtures.benchStringContains(); + benchStringIn = BenchFixtures.benchStringIn(); + benchBytesConst = BenchFixtures.benchBytesConst(); + benchBytesIn = BenchFixtures.benchBytesIn(); + benchInt64Const = BenchFixtures.benchInt64Const(); + benchInt64In = BenchFixtures.benchInt64In(); + benchUint32In = BenchFixtures.benchUint32In(); + benchDoubleIn = BenchFixtures.benchDoubleIn(); + benchEnumConst = BenchFixtures.benchEnumConst(); + benchEnumNotIn = BenchFixtures.benchEnumNotIn(); + benchRepeatedStringUnique = BenchFixtures.benchRepeatedStringUnique(); + benchRepeatedInt32Unique = BenchFixtures.benchRepeatedInt32Unique(); + // Warm evaluator cache for steady-state benchmarks. validator.validate(simple); validator.validate(manyUnruled); @@ -158,6 +209,22 @@ public void setup() throws ValidationException { validator.validate(multiRuleError); validator.validate(benchBoolConst); validator.validate(benchEnumRules); + validator.validate(benchStringConst); + validator.validate(benchStringLen); + validator.validate(benchStringMinLen); + validator.validate(benchStringPrefix); + validator.validate(benchStringContains); + validator.validate(benchStringIn); + validator.validate(benchBytesConst); + validator.validate(benchBytesIn); + validator.validate(benchInt64Const); + validator.validate(benchInt64In); + validator.validate(benchUint32In); + validator.validate(benchDoubleIn); + validator.validate(benchEnumConst); + validator.validate(benchEnumNotIn); + validator.validate(benchRepeatedStringUnique); + validator.validate(benchRepeatedInt32Unique); } // --- Existing regression-guard benchmarks --- @@ -258,4 +325,86 @@ public void validateBenchBoolConst(Blackhole bh) throws ValidationException { public void validateBenchEnumRules(Blackhole bh) throws ValidationException { bh.consume(validator.validate(benchEnumRules)); } + + // --- Single-rule fixtures filling earlier coverage gaps --- + + @Benchmark + public void validateBenchStringConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringConst)); + } + + @Benchmark + public void validateBenchStringLen(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringLen)); + } + + @Benchmark + public void validateBenchStringMinLen(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringMinLen)); + } + + @Benchmark + public void validateBenchStringPrefix(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringPrefix)); + } + + @Benchmark + public void validateBenchStringContains(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringContains)); + } + + @Benchmark + public void validateBenchStringIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchStringIn)); + } + + @Benchmark + public void validateBenchBytesConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchBytesConst)); + } + + @Benchmark + public void validateBenchBytesIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchBytesIn)); + } + + @Benchmark + public void validateBenchInt64Const(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchInt64Const)); + } + + @Benchmark + public void validateBenchInt64In(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchInt64In)); + } + + @Benchmark + public void validateBenchUint32In(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchUint32In)); + } + + @Benchmark + public void validateBenchDoubleIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchDoubleIn)); + } + + @Benchmark + public void validateBenchEnumConst(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchEnumConst)); + } + + @Benchmark + public void validateBenchEnumNotIn(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchEnumNotIn)); + } + + @Benchmark + public void validateBenchRepeatedStringUnique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedStringUnique)); + } + + @Benchmark + public void validateBenchRepeatedInt32Unique(Blackhole bh) throws ValidationException { + bh.consume(validator.validate(benchRepeatedInt32Unique)); + } } diff --git a/benchmarks/src/jmh/proto/bench/v1/native_bench.proto b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto index f35463f9..57ec69e4 100644 --- a/benchmarks/src/jmh/proto/bench/v1/native_bench.proto +++ b/benchmarks/src/jmh/proto/bench/v1/native_bench.proto @@ -222,4 +222,109 @@ message BenchEnumRules { 3 ] }]; +} + +// --- Single-rule fixtures for previously unbenchmarked rules --- +// +// These messages each isolate a single rule so the benchmark measures that rule's per-validation +// cost. They cover gaps identified in the second-pass review: string scalar rules (const, len, +// min_len, prefix, contains, in), bytes scalar rules (const, in), numeric in/const for non-int32 +// kinds, enum const/not_in, and repeated.unique on string/int32. + +message BenchStringConst { + string s = 1 [(buf.validate.field).string.const = "hello"]; +} + +message BenchStringLen { + string s = 1 [(buf.validate.field).string.len = 5]; +} + +message BenchStringMinLen { + string s = 1 [(buf.validate.field).string.min_len = 1]; +} + +message BenchStringPrefix { + string s = 1 [(buf.validate.field).string.prefix = "user-"]; +} + +message BenchStringContains { + string s = 1 [(buf.validate.field).string.contains = "@"]; +} + +message BenchStringIn { + string s = 1 [(buf.validate.field).string = { + in: [ + "foo", + "bar", + "baz" + ] + }]; +} + +message BenchBytesConst { + bytes b = 1 [(buf.validate.field).bytes.const = "abc"]; +} + +message BenchBytesIn { + bytes b = 1 [(buf.validate.field).bytes = { + in: [ + "foo", + "bar" + ] + }]; +} + +message BenchInt64Const { + int64 v = 1 [(buf.validate.field).int64.const = 42]; +} + +message BenchInt64In { + int64 v = 1 [(buf.validate.field).int64 = { + in: [ + 1, + 2, + 3 + ] + }]; +} + +message BenchUint32In { + uint32 v = 1 [(buf.validate.field).uint32 = { + in: [ + 1, + 2, + 3 + ] + }]; +} + +message BenchDoubleIn { + double v = 1 [(buf.validate.field).double = { + in: [ + 1.0, + 2.0, + 3.0 + ] + }]; +} + +message BenchEnumConst { + BenchPhaseEnum val = 1 [(buf.validate.field).enum.const = 1]; +} + +message BenchEnumNotIn { + BenchPhaseEnum val = 1 [(buf.validate.field).enum = { + not_in: [ + 2, + 3 + ] + }]; +} + +message BenchRepeatedStringUnique { + repeated string x = 1 [(buf.validate.field).repeated.unique = true]; +} + +message BenchRepeatedInt32Unique { + repeated int32 x = 1 [(buf.validate.field).repeated.unique = true]; } \ No newline at end of file From ea107a44ee800b29e1a71f44854993ed665c4637 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Mon, 4 May 2026 11:42:10 -0400 Subject: [PATCH 18/31] fix formatting and comments. Remove unused field. Add private constructor to prevent instantiation. Update README for benchmarks. Add new gradle task for comparing native rule performance. --- benchmarks/README.md | 20 +++++ benchmarks/build.gradle.kts | 23 ++++++ benchmarks/jmh-compare-params.jq | 27 +++++++ .../protovalidate/NativeRulesParityTest.java | 28 +++---- .../buf/protovalidate/CustomOverload.java | 25 ++++++ .../buf/protovalidate/ValueEvaluator.java | 15 ++++ .../rules/BytesRulesEvaluator.java | 3 - .../rules/NumericRulesEvaluator.java | 7 +- .../rules/RepeatedRulesEvaluator.java | 4 +- .../build/buf/protovalidate/rules/Rules.java | 1 - .../rules/StringRulesEvaluator.java | 16 ++-- .../buf/protovalidate/rules/FailFastTest.java | 6 +- .../rules/FloatBugConfirmationTest.java | 79 ++++++++++--------- .../rules/ResidualClearingTest.java | 6 +- .../rules/WellKnownRegexTest.java | 3 +- .../rules/WrappedValueEvaluatorTest.java | 14 ++-- 16 files changed, 194 insertions(+), 83 deletions(-) create mode 100644 benchmarks/jmh-compare-params.jq diff --git a/benchmarks/README.md b/benchmarks/README.md index 903120cd..3b93099b 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -54,6 +54,26 @@ compileValidatorForRepeated alloc 12950196.95 B/op 3262651.61 B/op -74.8% `jmhCompare` diffs `results-before.json` against `results.json` by default. Pass explicit paths with `-Pbefore= -Pafter=`. +## Comparing native rules vs CEL + +Benchmarks A/B the `enableNativeRules` flag via `@Param({"false", "true"})`, so a single run produces both variants. +Diff them in place: + +``` +./gradlew :benchmarks:jmh +./gradlew :benchmarks:jmhCompareParams +``` + +Output (`before` = CEL, `after` = native; negative delta means native is faster / allocates less): + +``` +benchmark metric cel native delta +buildBenchInt32GT time 1234567.89 ns/op 456789.01 ns/op -63.0% +buildBenchInt32GT alloc 123456.78 B/op 45678.90 B/op -63.0% +``` + +Override the input file with `-Presults=`. + ## Adding a new benchmark Benchmarks live in `src/jmh/java/...` and target proto messages in `src/jmh/proto/...`. diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 30bf1e1d..64663306 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -143,3 +143,26 @@ tasks.register("jmhCompare") { after, // $3 ) } + +// Diffs the two enableNativeRules variants within a single results.json. +// `before` column is CEL (enableNativeRules=false), `after` is native +// (enableNativeRules=true), so a negative delta means native is faster / +// allocates less. +// +// Override the input file: +// ./gradlew :benchmarks:jmhCompareParams -Presults=path/to/results.json +tasks.register("jmhCompareParams") { + description = "Diffs enableNativeRules=true vs false from a single JMH results.json." + val results = + project.findProperty("results")?.toString() + ?: jmhResults.get().asFile.absolutePath + val jqScript = file("jmh-compare-params.jq").absolutePath + commandLine( + "bash", + "-c", + "jq --raw-output --from-file \"\$1\" \"\$2\" | column -t -s \$'\\t'", + "jmh-compare-params", // $0 + jqScript, // $1 + results, // $2 + ) +} diff --git a/benchmarks/jmh-compare-params.jq b/benchmarks/jmh-compare-params.jq new file mode 100644 index 00000000..5645a11f --- /dev/null +++ b/benchmarks/jmh-compare-params.jq @@ -0,0 +1,27 @@ +def pct(a; b): + if a == null or b == null or b == 0 then "~" + else (((a - b) / b * 100) * 10 | round / 10) as $d + | if $d > 0 then "+\($d)%" elif $d == 0 then "~" else "\($d)%" end + end; +def num(x): + if x == null then "-" + else (x * 100 | round / 100 | tostring) + end; + +def row: { + key: (.benchmark | split(".") | last), + native: .params.enableNativeRules, + time: .primaryMetric.score, + unit: .primaryMetric.scoreUnit, + alloc: (.secondaryMetrics["·gc.alloc.rate.norm"].score // null) +}; + +map(row) +| group_by(.key) +| (["benchmark", "metric", "cel", "native", "delta"] | @tsv), + (.[] + | (map(select(.native == "false"))[0]) as $b + | (map(select(.native == "true"))[0]) as $a + | select($b and $a) + | ([$b.key, "time", "\(num($b.time)) \($b.unit)", "\(num($a.time)) \($a.unit)", pct($a.time; $b.time)] | @tsv), + ([$b.key, "alloc", "\(num($b.alloc)) B/op", "\(num($a.alloc)) B/op", pct($a.alloc; $b.alloc)] | @tsv)) \ No newline at end of file diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index cdebd365..88f6266c 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -46,16 +46,15 @@ import org.junit.jupiter.params.provider.MethodSource; /** - * Parity test: runs a representative slice of conformance fixtures through both modes - * ({@code enableNativeRules=true} and {@code false}) and asserts the resulting - * {@code Violation} protos are byte-equal. The conformance suite proves each mode is correct in - * isolation; this test proves they don't drift from each other on the same input. + * Parity test: runs a representative slice of conformance fixtures through both modes ({@code + * enableNativeRules=true} and {@code false}) and asserts the resulting {@code Violation} protos are + * byte-equal. The conformance suite proves each mode is correct in isolation; this test proves they + * don't drift from each other on the same input. * - *

    Conformance message text is excluded from the suite's default comparison (only - * {@code rule_id}, {@code field}, {@code rule}, {@code for_key} are compared unless - * {@code --strict_message} is set), but {@code toProto()} captures all of those plus the - * message text. Asserting full {@code toProto()} equality here is therefore stricter than - * conformance. + *

    Conformance message text is excluded from the suite's default comparison (only {@code + * rule_id}, {@code field}, {@code rule}, {@code for_key} are compared unless {@code + * --strict_message} is set), but {@code toProto()} captures all of those plus the message text. + * Asserting full {@code toProto()} equality here is therefore stricter than conformance. */ class NativeRulesParityTest { @@ -70,8 +69,8 @@ class NativeRulesParityTest { /** * Each entry exercises a different rule type. JUnit reports per-fixture pass/fail, so adding a - * fixture and watching CI is a single-line change. Order: - * bool, enum, bytes, numeric (signed + unsigned), string (scalar + format), repeated, map. + * fixture and watching CI is a single-line change. Order: bool, enum, bytes, numeric (signed + + * unsigned), string (scalar + format), repeated, map. */ static Stream fixtures() { return Stream.of( @@ -97,8 +96,7 @@ static Stream fixtures() { // String prefix — pass case. Arguments.of("StringPrefix.pass", StringPrefix.newBuilder().setVal("foo").build()), // String contains — pass case. - Arguments.of( - "StringContains.pass", StringContains.newBuilder().setVal("foobar").build()), + Arguments.of("StringContains.pass", StringContains.newBuilder().setVal("foobar").build()), // String length with code points — emoji counts as 1 each. Arguments.of("StringLen.emoji", StringLen.newBuilder().setVal("😅😄👾").build()), // Repeated exact — fails (2 items, exact=3). @@ -136,8 +134,6 @@ void parityForFixture(String name, Message msg) throws ValidationException { } private static List toProtoList(ValidationResult result) { - return result.getViolations().stream() - .map(Violation::toProto) - .collect(Collectors.toList()); + return result.getViolations().stream().map(Violation::toProto).collect(Collectors.toList()); } } diff --git a/src/main/java/build/buf/protovalidate/CustomOverload.java b/src/main/java/build/buf/protovalidate/CustomOverload.java index c1f60c82..8ed669a4 100644 --- a/src/main/java/build/buf/protovalidate/CustomOverload.java +++ b/src/main/java/build/buf/protovalidate/CustomOverload.java @@ -44,6 +44,9 @@ @Internal public final class CustomOverload { + // Prevent instantiation. + private CustomOverload() {} + // See https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address private static final Pattern EMAIL_REGEX = Pattern.compile( @@ -423,6 +426,10 @@ private static boolean matches( * *

    The port is separated by a colon. It must be non-empty, with a decimal number in the range * of 0-65535, inclusive. + * + * @param str The input string to validate as a host/port pair. + * @param portRequired Whether the port is required. + * @return {@code true} if the input string is a valid host/port pair, {@code false} otherwise. */ public static boolean isHostAndPort(String str, boolean portRequired) { if (str.isEmpty()) { @@ -527,6 +534,9 @@ public static boolean isEmail(String addr) { *

  • The name can have a trailing dot, for example "foo.example.com.". *
  • The name can be 253 characters at most, excluding the optional trailing dot. * + * + * @param val The input string to validate as a hostname. + * @return {@code true} if the input string is a valid hostname, {@code false} otherwise. */ public static boolean isHostname(String val) { if (val.length() > 253) { @@ -583,6 +593,10 @@ public static boolean isHostname(String val) { * *

    Both formats are well-defined in the internet standard RFC 3986. Zone identifiers for IPv6 * addresses (for example "fe80::a%en1") are supported. + * + * @param addr The input string to validate as an IPv4 or IPv6 address. + * @param ver The version of the address to validate. 0 means either 4 or 6. + * @return {@code true} if the input string is an IPv4 or IPv6 address, {@code false} otherwise. */ public static boolean isIp(String addr, long ver) { if (ver == 6L) { @@ -601,6 +615,9 @@ public static boolean isIp(String addr, long ver) { * *

    URI is defined in the internet standard RFC 3986. Zone Identifiers in IPv6 address literals * are supported (RFC 6874). + * + * @param str The input string to validate as a URI. + * @return {@code true} if the input string is a URI, {@code false} otherwise. */ public static boolean isUri(String str) { return new Uri(str).uri(); @@ -613,6 +630,9 @@ public static boolean isUri(String str) { * *

    URI, URI Reference, and Relative Reference are defined in the internet standard RFC 3986. * Zone Identifiers in IPv6 address literals are supported (RFC 6874). + * + * @param str The input string to validate as a URI Reference. + * @return {@code true} if the input string is a URI Reference, {@code false} otherwise. */ public static boolean isUriRef(String str) { return new Uri(str).uriReference(); @@ -634,6 +654,11 @@ public static boolean isUriRef(String str) { * *

    The same principle applies to IPv4 addresses. "192.168.1.0/24" designates the first 24 bits * of the 32-bit IPv4 as the network prefix. + * + * @param str The input string to validate as an IP with prefix length. + * @param version The version of the address to validate. 0 means either 4 or 6. + * @param strict Whether the host portion must be all zeros. + * @return {@code true} if the input string is a valid IP with prefix length, {@code false} */ public static boolean isIpPrefix(String str, long version, boolean strict) { if (version == 6L) { diff --git a/src/main/java/build/buf/protovalidate/ValueEvaluator.java b/src/main/java/build/buf/protovalidate/ValueEvaluator.java index 2791372f..bfbbafe5 100644 --- a/src/main/java/build/buf/protovalidate/ValueEvaluator.java +++ b/src/main/java/build/buf/protovalidate/ValueEvaluator.java @@ -56,14 +56,29 @@ public final class ValueEvaluator implements Evaluator { this.nestedRule = nestedRule; } + /** + * Returns the {@link Descriptors.FieldDescriptor} targeted by this evaluator. + * + * @return The {@link Descriptors.FieldDescriptor} targeted by this evaluator. + */ public Descriptors.@Nullable FieldDescriptor getDescriptor() { return descriptor; } + /** + * Returns the nested rule path that this value evaluator is for. + * + * @return The nested rule path that this value evaluator is for. + */ public @Nullable FieldPath getNestedRule() { return nestedRule; } + /** + * Returns true if this value evaluator is for a nested rule. + * + * @return {@code true} if this value evaluator is for a nested rule, {@code false} otherwise. + */ public boolean hasNestedRule() { return this.nestedRule != null; } diff --git a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java index 0a967dd8..04c134a2 100644 --- a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java @@ -71,7 +71,6 @@ private enum WellKnown { final RuleSite site; final RuleSite emptySite; final List validSizes; - final FieldDescriptor field; WellKnown( String ruleId, @@ -81,7 +80,6 @@ private enum WellKnown { List validSizes, int fieldNumber) { FieldDescriptor leaf = BytesRules.getDescriptor().findFieldByNumber(fieldNumber); - this.field = leaf; this.site = RuleSite.of(BYTES_RULES_DESC, leaf, ruleId, message); this.emptySite = RuleSite.of(BYTES_RULES_DESC, leaf, emptyRuleId, emptyMessage); this.validSizes = Collections.unmodifiableList(validSizes); @@ -517,5 +515,4 @@ private static String hex(ByteString bs) { private static String formatList(List vals) { return RuleBase.formatList(vals, ByteString::toStringUtf8); } - } diff --git a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java index 0897c4d4..4daf5958 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java @@ -93,8 +93,11 @@ private NumericRulesEvaluator( * carries no rule we cover. On success, clears the covered fields on the builder so CEL doesn't * also compile programs for them. * - * @param rulesField the {@code FieldRules} oneof field for this kind (e.g. the {@code int32} - * field on {@code FieldRules}) + * @param base the base rule evaluator. + * @param rulesBuilder the builder for the rules sub-message. + * @param config the config for the numeric type this evaluator is for. + * @return a new evaluator, or null if the sub-message is unset, has unknown fields, or carries no + * rule we cover. */ static > @Nullable Evaluator tryBuild( RuleBase base, FieldRules.Builder rulesBuilder, NumericTypeConfig config) { diff --git a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java index 64ab8cb2..c753e792 100644 --- a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java @@ -191,7 +191,8 @@ public List evaluate(Value val, boolean failFast) { } /** - * Returns true iff every element in {@code list} is distinct. Uses a {@link HashSet} to test for uniqueness. + * Returns true iff every element in {@code list} is distinct. Uses a {@link HashSet} to test for + * uniqueness. */ private static boolean isUnique(List list) { int size = list.size(); @@ -206,5 +207,4 @@ private static boolean isUnique(List list) { } return true; } - } diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/rules/Rules.java index 35071bd1..33afb2e8 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/rules/Rules.java @@ -148,5 +148,4 @@ private Rules() {} RuleBase base, FieldRules.Builder rulesBuilder, NumericTypeConfig config) { return NumericRulesEvaluator.tryBuild(base, rulesBuilder, (NumericTypeConfig) config); } - } diff --git a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java index 1f52a406..e5ad8996 100644 --- a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java @@ -118,12 +118,12 @@ private static RuleSite site(int fieldNumber, String ruleId) { // --- Well-known string formats --- /** - * Each constant carries the rule id, the main violation message, the empty-value variant - * message, and the validation. Empty messages are stored verbatim from the proto spec rather - * than derived by string substitution: {@code host_and_port}, for example, has a main message - * of {@code "must be a valid host (hostname or IP address) and port pair"} but an empty message - * of {@code "value is empty, which is not a valid host and port pair"} (without the - * parenthetical) — substring derivation produced the wrong text. + * Each constant carries the rule id, the main violation message, the empty-value variant message, + * and the validation. Empty messages are stored verbatim from the proto spec rather than derived + * by string substitution: {@code host_and_port}, for example, has a main message of {@code "must + * be a valid host (hostname or IP address) and port pair"} but an empty message of {@code "value + * is empty, which is not a valid host and port pair"} (without the parenthetical) — substring + * derivation produced the wrong text. */ @SuppressWarnings("ImmutableEnumChecker") // RuleSite is logically immutable; not annotated. enum WellKnownFormat { @@ -821,7 +821,8 @@ private static long utf8ByteLength(String s) { } else if (c < 0x800) { count += 2; i += 1; - } else if (Character.isHighSurrogate(c) && i + 1 < len + } else if (Character.isHighSurrogate(c) + && i + 1 < len && Character.isLowSurrogate(s.charAt(i + 1))) { count += 4; i += 2; @@ -832,5 +833,4 @@ private static long utf8ByteLength(String s) { } return count; } - } diff --git a/src/test/java/build/buf/protovalidate/rules/FailFastTest.java b/src/test/java/build/buf/protovalidate/rules/FailFastTest.java index 99f5c812..a9b072f6 100644 --- a/src/test/java/build/buf/protovalidate/rules/FailFastTest.java +++ b/src/test/java/build/buf/protovalidate/rules/FailFastTest.java @@ -29,9 +29,9 @@ import org.junit.jupiter.api.Test; /** - * failFast tests for the native rule evaluators. Each fixture is constructed so the input - * violates two rules. Without failFast, both violations are reported; with failFast=true, the - * validator must short-circuit after the first. + * failFast tests for the native rule evaluators. Each fixture is constructed so the input violates + * two rules. Without failFast, both violations are reported; with failFast=true, the validator must + * short-circuit after the first. */ class FailFastTest { diff --git a/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java b/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java index de48c96e..44dd5f26 100644 --- a/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java +++ b/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java @@ -39,17 +39,16 @@ *

    Outcomes after running: * *

      - *
    • B1 — fixed. {@code floatFormatter}/{@code doubleFormatter} now check the sign bit - * on entry and return {@code "-0"} for negative zero, so {@code float.const = -0.0} produces - * the same violation message in both modes. The tests below lock in that parity. - *
    • B2 — reclassified. Original review claimed native diverges from CEL on - * {@code repeated.unique} for {@code NaN}/{@code -0.0}. Investigation showed CEL's - * {@code unique()} is registered by protovalidate-java itself (see - * {@code CustomOverload.uniqueList}) and uses {@code Object.equals} on a {@link - * java.util.HashSet} — the same defect as {@code RepeatedRulesEvaluator.isUnique}. So both - * paths agree (both deviate from the CEL spec, which mandates IEEE-754 equality on - * doubles). The tests below lock in that agreement so any future fix has to touch both - * paths together. + *
    • B1 — fixed. {@code floatFormatter}/{@code doubleFormatter} now check the sign bit on + * entry and return {@code "-0"} for negative zero, so {@code float.const = -0.0} produces the + * same violation message in both modes. The tests below lock in that parity. + *
    • B2 — reclassified. Original review claimed native diverges from CEL on {@code + * repeated.unique} for {@code NaN}/{@code -0.0}. Investigation showed CEL's {@code unique()} + * is registered by protovalidate-java itself (see {@code CustomOverload.uniqueList}) and uses + * {@code Object.equals} on a {@link java.util.HashSet} — the same defect as {@code + * RepeatedRulesEvaluator.isUnique}. So both paths agree (both deviate from the CEL spec, + * which mandates IEEE-754 equality on doubles). The tests below lock in that agreement so any + * future fix has to touch both paths together. *
    */ class FloatBugConfirmationTest { @@ -132,31 +131,37 @@ void doubleRepeatedUnique_PlusZeroMinusZero_bothPathsAgreeBothWrongVsSpec() @Test void floatDoubleNaNNegZero() throws ValidationException { - // these tests are also checking that an unset (zero) field is equal to -0 - FloatDoubleNaNNegZero nanMsg = FloatDoubleNaNNegZero.newBuilder(). - addDvals(Double.NaN).addDvals(Double.NaN). - addFvals(Float.NaN).addFvals(Float.NaN). - build(); - // should both be no error, since NaN is not equal to itself - // it's not because Java CEL is broken so replicate broken behavior - ValidationResult nanMsgResultNative = nativeValidator.validate(nanMsg); - ValidationResult nanMsgResultCEL = celValidator.validate(nanMsg); - assertViolationsEqual(nanMsg); - assertThat(nanMsgResultNative.getViolations()).isNotEmpty(); - assertThat(nanMsgResultCEL.getViolations()).isNotEmpty(); - - // now check -0 and 0 for uniqueness (should not be) - FloatDoubleNaNNegZero zeroMsg = FloatDoubleNaNNegZero.newBuilder(). - addDvals(0.0).addDvals(-0.0). - addFvals(0.0F).addFvals(-0.0F). - build(); - // should both be error, since 0 == -0 - // but it's not because Java CEL is broken on unique tests for -0 so replicate broken behavior - nanMsgResultNative = nativeValidator.validate(zeroMsg); - nanMsgResultCEL = celValidator.validate(zeroMsg); - assertViolationsEqual(zeroMsg); - assertThat(nanMsgResultNative.getViolations()).isEmpty(); - assertThat(nanMsgResultCEL.getViolations()).isEmpty(); + // these tests are also checking that an unset (zero) field is equal to -0 + FloatDoubleNaNNegZero nanMsg = + FloatDoubleNaNNegZero.newBuilder() + .addDvals(Double.NaN) + .addDvals(Double.NaN) + .addFvals(Float.NaN) + .addFvals(Float.NaN) + .build(); + // should both be no error, since NaN is not equal to itself + // it's not because Java CEL is broken so replicate broken behavior + ValidationResult nanMsgResultNative = nativeValidator.validate(nanMsg); + ValidationResult nanMsgResultCEL = celValidator.validate(nanMsg); + assertViolationsEqual(nanMsg); + assertThat(nanMsgResultNative.getViolations()).isNotEmpty(); + assertThat(nanMsgResultCEL.getViolations()).isNotEmpty(); + + // now check -0 and 0 for uniqueness (should not be) + FloatDoubleNaNNegZero zeroMsg = + FloatDoubleNaNNegZero.newBuilder() + .addDvals(0.0) + .addDvals(-0.0) + .addFvals(0.0F) + .addFvals(-0.0F) + .build(); + // should both be error, since 0 == -0 + // but it's not because Java CEL is broken on unique tests for -0 so replicate broken behavior + nanMsgResultNative = nativeValidator.validate(zeroMsg); + nanMsgResultCEL = celValidator.validate(zeroMsg); + assertViolationsEqual(zeroMsg); + assertThat(nanMsgResultNative.getViolations()).isEmpty(); + assertThat(nanMsgResultCEL.getViolations()).isEmpty(); } // --- helpers ---------------------------------------------------------------------------------- @@ -183,4 +188,4 @@ private void assertViolationsEqual(Message msg) throws ValidationException { private static List toProtoList(ValidationResult result) { return result.getViolations().stream().map(v -> v.toProto()).collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java b/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java index 9bf3b324..fb880c47 100644 --- a/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java +++ b/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java @@ -82,8 +82,10 @@ void mapMinPairsFiresOnce() throws ValidationException { private void assertExactlyOneViolation(Message msg) throws ValidationException { ValidationResult result = nativeValidator.validate(msg); assertThat(result.getViolations()) - .as("native dispatcher must clear the rule from the residual; expected exactly one " - + "violation but got: %s", result.getViolations()) + .as( + "native dispatcher must clear the rule from the residual; expected exactly one " + + "violation but got: %s", + result.getViolations()) .hasSize(1); } } diff --git a/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java b/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java index 0746a809..7a73e501 100644 --- a/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java +++ b/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java @@ -61,8 +61,7 @@ void headerName_emptyValue_firesEmptyVariant() throws ValidationException { assertThat(result.getViolations()).hasSize(1); build.buf.validate.Violation v = result.getViolations().get(0).toProto(); assertThat(v.getRuleId()).isEqualTo("string.well_known_regex.header_name_empty"); - assertThat(v.getMessage()) - .isEqualTo("value is empty, which is not a valid HTTP header name"); + assertThat(v.getMessage()).isEqualTo("value is empty, which is not a valid HTTP header name"); } @Test diff --git a/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java index 4ad68e77..7809bef4 100644 --- a/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java @@ -28,9 +28,9 @@ import org.junit.jupiter.api.Test; /** - * Targeted tests for {@link WrappedValueEvaluator}. Mirrors the wrapper-unwrap path that the - * native dispatcher takes for {@code google.protobuf.*Value} fields. The conformance suite covers - * one wrapper kind (DoubleValue) via parity tests; these cover the unwrap invariant directly. + * Targeted tests for {@link WrappedValueEvaluator}. Mirrors the wrapper-unwrap path that the native + * dispatcher takes for {@code google.protobuf.*Value} fields. The conformance suite covers one + * wrapper kind (DoubleValue) via parity tests; these cover the unwrap invariant directly. */ class WrappedValueEvaluatorTest { @@ -65,7 +65,9 @@ void presentWrapperWithDefaultInnerValueFiresRule() throws ValidationException { @Test void presentWrapperWithViolatingValueProducesExpectedShape() throws ValidationException { StringWrapperLen msg = - StringWrapperLen.newBuilder().setVal(StringValue.newBuilder().setValue("ab").build()).build(); + StringWrapperLen.newBuilder() + .setVal(StringValue.newBuilder().setValue("ab").build()) + .build(); ValidationResult result = nativeValidator.validate(msg); assertThat(result.getViolations()).hasSize(1); build.buf.validate.Violation proto = result.getViolations().get(0).toProto(); @@ -77,9 +79,7 @@ void presentWrapperWithViolatingValueProducesExpectedShape() throws ValidationEx @Test void presentWrapperWithPassingValueProducesNoViolation() throws ValidationException { Int64WrapperConst msg = - Int64WrapperConst.newBuilder() - .setVal(Int64Value.newBuilder().setValue(5).build()) - .build(); + Int64WrapperConst.newBuilder().setVal(Int64Value.newBuilder().setValue(5).build()).build(); assertThat(nativeValidator.validate(msg).isSuccess()).isTrue(); } } From b7fdc0f9de6b7f4158d23859d3dee13f33c22c3c Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Mon, 4 May 2026 12:44:08 -0400 Subject: [PATCH 19/31] default to native rules enabled. There are now two Builder methods, setEnableNativeRules() and setDisableNativeRules(). If neither is used, protovalidate-java defaults to native rules enabled. --- README.md | 4 +-- .../benchmarks/EvaluatorBuildBenchmark.java | 6 +++- .../benchmarks/ValidationBenchmark.java | 8 ++++- .../buf/protovalidate/conformance/Main.java | 7 ++++- .../protovalidate/NativeRulesParityTest.java | 4 +-- .../java/build/buf/protovalidate/Config.java | 30 +++++++++++-------- .../rules/BoolRulesEvaluatorTest.java | 4 +-- .../rules/BytesRulesEvaluatorTest.java | 4 +-- .../rules/EnumRulesEvaluatorTest.java | 4 +-- .../buf/protovalidate/rules/FailFastTest.java | 2 +- .../rules/FloatBugConfirmationTest.java | 4 +-- .../protovalidate/rules/NotInRulesTest.java | 2 +- .../rules/NumericRulesEvaluatorTest.java | 4 +-- .../RepeatedAndMapRulesEvaluatorTest.java | 4 +-- .../rules/ResidualClearingTest.java | 2 +- .../rules/StringRulesEvaluatorTest.java | 4 +-- .../rules/WellKnownRegexTest.java | 2 +- .../rules/WrappedValueEvaluatorTest.java | 2 +- 18 files changed, 59 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 5b9591a2..281f3f6e 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,10 @@ Highlights for Java developers include: The standard rules can be evaluated either through CEL or through native Java code. Native evaluation is functionally identical (the conformance suite passes in both modes) but skips CEL compilation and runtime overhead for the rules it covers — a single `validate()` call on a complex message can run an order of magnitude faster and allocate ~10× less. -Native rules are **opt-in** while the implementation matures. Enable them by configuring the validator: +Native rules are **opt-out**. Disable them by configuring the validator: ```java -Config config = Config.newBuilder().setEnableNativeRules(true).build(); +Config config = Config.newBuilder().setDisableNativeRules().build(); Validator validator = ValidatorFactory.newBuilder().withConfig(config).build(); ``` diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java index 8f22a121..44b16f0d 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java @@ -51,7 +51,11 @@ public class EvaluatorBuildBenchmark { @Setup public void setup() { - config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); + if (enableNativeRules) { + config = Config.newBuilder().setEnableNativeRules().build(); + } else { + config = Config.newBuilder().setDisableNativeRules().build(); + } benchComplexSchema = BenchComplexSchema.getDefaultInstance(); benchGT = BenchGT.getDefaultInstance(); } diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index 9476c393..b280047e 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -129,7 +129,13 @@ public class ValidationBenchmark { @Setup public void setup() throws ValidationException { - Config config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); + Config config; + if (enableNativeRules) { + config = Config.newBuilder().setEnableNativeRules().build(); + } else { + config = Config.newBuilder().setDisableNativeRules().build(); + } + validator = ValidatorFactory.newBuilder().withConfig(config).build(); simple = SimpleStringMessage.newBuilder().setEmail("alice@example.com").build(); diff --git a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java index 38c83f0b..999f3a24 100644 --- a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java +++ b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java @@ -68,7 +68,12 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) { Config.Builder cfgBuilder = Config.newBuilder().setTypeRegistry(typeRegistry).setExtensionRegistry(extensionRegistry); if (envFlag != null) { - cfgBuilder.setEnableNativeRules(Boolean.parseBoolean(envFlag)); + boolean enableNativeRules = Boolean.parseBoolean(envFlag); + if (enableNativeRules) { + cfgBuilder.setEnableNativeRules(); + } else { + cfgBuilder.setDisableNativeRules(); + } } Config cfg = cfgBuilder.build(); Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build(); diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index 88f6266c..64f49ce1 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -60,11 +60,11 @@ class NativeRulesParityTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules().build()) .build(); private final Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); /** diff --git a/src/main/java/build/buf/protovalidate/Config.java b/src/main/java/build/buf/protovalidate/Config.java index 8fff81b8..589cb36b 100644 --- a/src/main/java/build/buf/protovalidate/Config.java +++ b/src/main/java/build/buf/protovalidate/Config.java @@ -91,9 +91,8 @@ public boolean isAllowingUnknownFields() { * Checks whether native (non-CEL) rule evaluators are enabled. * *

    When true, standard rules with a native Java implementation bypass CEL evaluation. When - * false, all rules go through CEL. Defaults to false while the native-rules implementation - * matures; applications opt in by calling {@link Builder#setEnableNativeRules(boolean) - * setEnableNativeRules(true)}. + * false, all rules go through CEL. Defaults to true; applications opt out by calling {@link + * Builder#setDisableNativeRules() setDisableNativeRules()}. * * @return true if native rules are enabled. */ @@ -107,7 +106,8 @@ public static final class Builder { private TypeRegistry typeRegistry = DEFAULT_TYPE_REGISTRY; private ExtensionRegistry extensionRegistry = DEFAULT_EXTENSION_REGISTRY; private boolean allowUnknownFields; - private boolean enableNativeRules; + // native rules are enabled by default + private boolean enableNativeRules = true; private Builder() {} @@ -176,17 +176,23 @@ public Builder setAllowUnknownFields(boolean allowUnknownFields) { } /** - * Set whether native (non-CEL) rule evaluators are enabled. Defaults to false while the - * native-rules implementation matures; pass true to opt in to native evaluation of standard - * rules. Forward-compatible: any rule not yet implemented natively continues to be enforced via - * CEL regardless of this setting. + * Enables native (non-CEL) rule evaluators. Forward-compatible: any rule not yet implemented + * natively continues to be enforced via CEL regardless of this setting. * - * @param enableNativeRules true to use native evaluators where available; false to route - * everything through CEL. * @return this builder */ - public Builder setEnableNativeRules(boolean enableNativeRules) { - this.enableNativeRules = enableNativeRules; + public Builder setEnableNativeRules() { + this.enableNativeRules = true; + return this; + } + + /** + * Disables native (non-CEL) rule evaluators. + * + * @return this builder + */ + public Builder setDisableNativeRules() { + this.enableNativeRules = false; return this; } diff --git a/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java index ea405995..dc5e7775 100644 --- a/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java @@ -36,7 +36,7 @@ class BoolRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules(true).build(); + Config config = Config.newBuilder().setEnableNativeRules().build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -93,7 +93,7 @@ void nativeAndCelProducePartiallyEqualViolations() throws ValidationException { ValidationResult nativeResult = nativeValidator().validate(msg); Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); ValidationResult celResult = celValidator.validate(msg); diff --git a/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java index 578337d1..3d809437 100644 --- a/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java @@ -36,7 +36,7 @@ class BytesRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules(true).build(); + Config config = Config.newBuilder().setEnableNativeRules().build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -120,7 +120,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java index 44ccb0c8..4ecef203 100644 --- a/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java @@ -33,7 +33,7 @@ class EnumRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules(true).build(); + Config config = Config.newBuilder().setEnableNativeRules().build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -84,7 +84,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/rules/FailFastTest.java b/src/test/java/build/buf/protovalidate/rules/FailFastTest.java index a9b072f6..3c0f9a75 100644 --- a/src/test/java/build/buf/protovalidate/rules/FailFastTest.java +++ b/src/test/java/build/buf/protovalidate/rules/FailFastTest.java @@ -36,7 +36,7 @@ class FailFastTest { private static Validator validator(boolean failFast) { - Config config = Config.newBuilder().setEnableNativeRules(true).setFailFast(failFast).build(); + Config config = Config.newBuilder().setEnableNativeRules().setFailFast(failFast).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } diff --git a/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java b/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java index 44dd5f26..8d71d02d 100644 --- a/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java +++ b/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java @@ -55,11 +55,11 @@ class FloatBugConfirmationTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules().build()) .build(); private final Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); // --- B1: floatFormatter renders -0.0 as "0", losing the sign -------------------------------- diff --git a/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java b/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java index 17c9b595..28423316 100644 --- a/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java +++ b/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java @@ -39,7 +39,7 @@ class NotInRulesTest { private final Validator validator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules().build()) .build(); @Test diff --git a/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java index c340211e..da1f4fea 100644 --- a/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java @@ -49,7 +49,7 @@ class NumericRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules(true).build(); + Config config = Config.newBuilder().setEnableNativeRules().build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -160,7 +160,7 @@ void nativeAndCelProduceEqualViolationProtos() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) diff --git a/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java index 394bda49..f4dd8cfe 100644 --- a/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java @@ -30,7 +30,7 @@ class RepeatedAndMapRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules(true).build(); + Config config = Config.newBuilder().setEnableNativeRules().build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -117,7 +117,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java b/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java index fb880c47..76e5e99c 100644 --- a/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java +++ b/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java @@ -41,7 +41,7 @@ class ResidualClearingTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules().build()) .build(); @Test diff --git a/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java index 5bb81feb..88efede9 100644 --- a/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java @@ -34,7 +34,7 @@ class StringRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules(true).build(); + Config config = Config.newBuilder().setEnableNativeRules().build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -130,7 +130,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) + .withConfig(Config.newBuilder().setDisableNativeRules().build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java b/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java index 7a73e501..948abe27 100644 --- a/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java +++ b/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java @@ -34,7 +34,7 @@ class WellKnownRegexTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules().build()) .build(); @Test diff --git a/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java b/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java index 7809bef4..0c6d2e99 100644 --- a/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java @@ -36,7 +36,7 @@ class WrappedValueEvaluatorTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) + .withConfig(Config.newBuilder().setEnableNativeRules().build()) .build(); @Test From 9e45d6de99421169d6d8aea632dee8229362c250 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Mon, 4 May 2026 12:48:38 -0400 Subject: [PATCH 20/31] update the conformance workflow and the makefile to validate cel and native implementations correctly. --- .github/workflows/conformance.yaml | 6 +++--- Makefile | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index 96592693..e7aff5bf 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -37,7 +37,7 @@ jobs: token: ${{ secrets.BUF_TOKEN }} - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - - name: Test conformance (CEL-only — default mode) + - name: Test conformance (CEL-only) + run: make conformance-cel + - name: Test conformance (native rules enabled — default mode) run: make conformance - - name: Test conformance (native rules enabled) - run: make conformance-native diff --git a/Makefile b/Makefile index 07f6e005..5f42293f 100644 --- a/Makefile +++ b/Makefile @@ -28,12 +28,12 @@ checkgenerate: generate ## Checks if `make generate` produces a diff. clean: ## Delete intermediate build artifacts $(GRADLE) clean -.PHONY: conformance -conformance: ## Execute conformance tests with default (CEL-only) rule evaluation. - $(GRADLE) conformance:conformance +.PHONY: conformance-cel +conformance-cel: ## Execute conformance tests with CEL-only rule evaluation. + ENABLE_NATIVE_RULES=false $(GRADLE) conformance:conformance -.PHONY: conformance-native -conformance-native: ## Execute conformance tests with native rule evaluators enabled. +.PHONY: conformance +conformance: ## Execute conformance tests with native rule evaluators enabled. ENABLE_NATIVE_RULES=true $(GRADLE) conformance:conformance .PHONY: help From ec8d659aecac76e8af79a557cf3659c7e09d55df Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Mon, 4 May 2026 17:34:40 -0400 Subject: [PATCH 21/31] fix validation message for string.not_contains --- .../build/buf/protovalidate/rules/StringRulesEvaluator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java index e5ad8996..9231b542 100644 --- a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java @@ -643,7 +643,7 @@ public List evaluate(Value val, boolean failFast) { NativeViolations.newViolation( NOT_CONTAINS_SITE, null, - "value contains substring `" + notContains + "`", + "contains substring `" + notContains + "`", val, notContains)); if (failFast) return base.done(violations); From 6374e26dc1f8b5b49c9dd0d3ce3527b2a7771dd3 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 10:46:34 -0400 Subject: [PATCH 22/31] remove the rules package, just put all the code in the protovalidate package. --- .../{rules => }/BoolRulesEvaluator.java | 5 +- .../{rules => }/BytesRulesEvaluator.java | 5 +- .../buf/protovalidate/CustomOverload.java | 50 ++++--------------- .../{rules => }/EnumRulesEvaluator.java | 5 +- .../build/buf/protovalidate/Evaluator.java | 6 +-- .../buf/protovalidate/EvaluatorBuilder.java | 1 - .../buf/protovalidate/FieldPathUtils.java | 16 ++---- .../build/buf/protovalidate/Internal.java | 30 ----------- .../{rules => }/MapRulesEvaluator.java | 5 +- .../{rules => }/NativeViolations.java | 4 +- .../{rules => }/NumericDescriptors.java | 2 +- .../{rules => }/NumericRulesEvaluator.java | 5 +- .../{rules => }/NumericTypeConfig.java | 2 +- .../build/buf/protovalidate/ObjectValue.java | 13 ++--- .../{rules => }/RepeatedRulesEvaluator.java | 5 +- .../protovalidate/{rules => }/RuleBase.java | 5 +- .../protovalidate/{rules => }/RuleSite.java | 3 +- .../buf/protovalidate/RuleViolation.java | 39 +++++++-------- .../buf/protovalidate/{rules => }/Rules.java | 6 +-- .../{rules => }/StringRulesEvaluator.java | 6 +-- .../java/build/buf/protovalidate/Value.java | 6 +-- .../buf/protovalidate/ValueEvaluator.java | 1 - .../{rules => }/WrappedValueEvaluator.java | 6 +-- .../{rules => }/BoolRulesEvaluatorTest.java | 7 +-- .../{rules => }/BytesRulesEvaluatorTest.java | 7 +-- .../{rules => }/EnumRulesEvaluatorTest.java | 7 +-- .../{rules => }/FailFastTest.java | 6 +-- .../{rules => }/FloatBugConfirmationTest.java | 6 +-- .../{rules => }/NotInRulesTest.java | 6 +-- .../NumericRulesEvaluatorTest.java | 7 +-- .../RepeatedAndMapRulesEvaluatorTest.java | 6 +-- .../{rules => }/ResidualClearingTest.java | 6 +-- .../{rules => }/StringRulesEvaluatorTest.java | 7 +-- .../{rules => }/WellKnownRegexTest.java | 6 +-- .../WrappedValueEvaluatorTest.java | 6 +-- 35 files changed, 62 insertions(+), 241 deletions(-) rename src/main/java/build/buf/protovalidate/{rules => }/BoolRulesEvaluator.java (94%) rename src/main/java/build/buf/protovalidate/{rules => }/BytesRulesEvaluator.java (98%) rename src/main/java/build/buf/protovalidate/{rules => }/EnumRulesEvaluator.java (97%) delete mode 100644 src/main/java/build/buf/protovalidate/Internal.java rename src/main/java/build/buf/protovalidate/{rules => }/MapRulesEvaluator.java (96%) rename src/main/java/build/buf/protovalidate/{rules => }/NativeViolations.java (96%) rename src/main/java/build/buf/protovalidate/{rules => }/NumericDescriptors.java (99%) rename src/main/java/build/buf/protovalidate/{rules => }/NumericRulesEvaluator.java (98%) rename src/main/java/build/buf/protovalidate/{rules => }/NumericTypeConfig.java (99%) rename src/main/java/build/buf/protovalidate/{rules => }/RepeatedRulesEvaluator.java (97%) rename src/main/java/build/buf/protovalidate/{rules => }/RuleBase.java (96%) rename src/main/java/build/buf/protovalidate/{rules => }/RuleSite.java (97%) rename src/main/java/build/buf/protovalidate/{rules => }/Rules.java (97%) rename src/main/java/build/buf/protovalidate/{rules => }/StringRulesEvaluator.java (99%) rename src/main/java/build/buf/protovalidate/{rules => }/WrappedValueEvaluator.java (93%) rename src/test/java/build/buf/protovalidate/{rules => }/BoolRulesEvaluatorTest.java (95%) rename src/test/java/build/buf/protovalidate/{rules => }/BytesRulesEvaluatorTest.java (95%) rename src/test/java/build/buf/protovalidate/{rules => }/EnumRulesEvaluatorTest.java (93%) rename src/test/java/build/buf/protovalidate/{rules => }/FailFastTest.java (92%) rename src/test/java/build/buf/protovalidate/{rules => }/FloatBugConfirmationTest.java (97%) rename src/test/java/build/buf/protovalidate/{rules => }/NotInRulesTest.java (94%) rename src/test/java/build/buf/protovalidate/{rules => }/NumericRulesEvaluatorTest.java (96%) rename src/test/java/build/buf/protovalidate/{rules => }/RepeatedAndMapRulesEvaluatorTest.java (95%) rename src/test/java/build/buf/protovalidate/{rules => }/ResidualClearingTest.java (93%) rename src/test/java/build/buf/protovalidate/{rules => }/StringRulesEvaluatorTest.java (95%) rename src/test/java/build/buf/protovalidate/{rules => }/WellKnownRegexTest.java (95%) rename src/test/java/build/buf/protovalidate/{rules => }/WrappedValueEvaluatorTest.java (94%) diff --git a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java b/src/main/java/build/buf/protovalidate/BoolRulesEvaluator.java similarity index 94% rename from src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java rename to src/main/java/build/buf/protovalidate/BoolRulesEvaluator.java index c54a8185..0ed7430f 100644 --- a/src/main/java/build/buf/protovalidate/rules/BoolRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/BoolRulesEvaluator.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.validate.BoolRules; import build.buf.validate.FieldRules; import com.google.protobuf.Descriptors.FieldDescriptor; diff --git a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java b/src/main/java/build/buf/protovalidate/BytesRulesEvaluator.java similarity index 98% rename from src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java rename to src/main/java/build/buf/protovalidate/BytesRulesEvaluator.java index 04c134a2..afa24ba1 100644 --- a/src/main/java/build/buf/protovalidate/rules/BytesRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/BytesRulesEvaluator.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.protovalidate.exceptions.ExecutionException; import build.buf.validate.BytesRules; import build.buf.validate.FieldRules; diff --git a/src/main/java/build/buf/protovalidate/CustomOverload.java b/src/main/java/build/buf/protovalidate/CustomOverload.java index 8ed669a4..d5db6ec0 100644 --- a/src/main/java/build/buf/protovalidate/CustomOverload.java +++ b/src/main/java/build/buf/protovalidate/CustomOverload.java @@ -34,18 +34,8 @@ import java.util.Set; import java.util.concurrent.ConcurrentMap; -/** - * Defines custom function overloads (the implementation). - * - *

    Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can reuse the - * format-validation helpers ({@link #isEmail}, {@link #isHostname}, {@link #isIp}, etc.); not part - * of the supported public API. - */ -@Internal -public final class CustomOverload { - - // Prevent instantiation. - private CustomOverload() {} +/** Defines custom function overloads (the implementation). */ +final class CustomOverload { // See https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address private static final Pattern EMAIL_REGEX = @@ -426,12 +416,8 @@ private static boolean matches( * *

    The port is separated by a colon. It must be non-empty, with a decimal number in the range * of 0-65535, inclusive. - * - * @param str The input string to validate as a host/port pair. - * @param portRequired Whether the port is required. - * @return {@code true} if the input string is a valid host/port pair, {@code false} otherwise. */ - public static boolean isHostAndPort(String str, boolean portRequired) { + static boolean isHostAndPort(String str, boolean portRequired) { if (str.isEmpty()) { return false; } @@ -517,7 +503,7 @@ private static boolean uniqueList(List list) throws CelEvaluationException { * @param addr The input string to validate as an email address. * @return {@code true} if the input string is a valid email address, {@code false} otherwise. */ - public static boolean isEmail(String addr) { + static boolean isEmail(String addr) { return EMAIL_REGEX.matcher(addr).matches(); } @@ -534,11 +520,8 @@ public static boolean isEmail(String addr) { *

  • The name can have a trailing dot, for example "foo.example.com.". *
  • The name can be 253 characters at most, excluding the optional trailing dot. * - * - * @param val The input string to validate as a hostname. - * @return {@code true} if the input string is a valid hostname, {@code false} otherwise. */ - public static boolean isHostname(String val) { + static boolean isHostname(String val) { if (val.length() > 253) { return false; } @@ -593,12 +576,8 @@ public static boolean isHostname(String val) { * *

    Both formats are well-defined in the internet standard RFC 3986. Zone identifiers for IPv6 * addresses (for example "fe80::a%en1") are supported. - * - * @param addr The input string to validate as an IPv4 or IPv6 address. - * @param ver The version of the address to validate. 0 means either 4 or 6. - * @return {@code true} if the input string is an IPv4 or IPv6 address, {@code false} otherwise. */ - public static boolean isIp(String addr, long ver) { + static boolean isIp(String addr, long ver) { if (ver == 6L) { return new Ipv6(addr).address(); } else if (ver == 4L) { @@ -615,11 +594,8 @@ public static boolean isIp(String addr, long ver) { * *

    URI is defined in the internet standard RFC 3986. Zone Identifiers in IPv6 address literals * are supported (RFC 6874). - * - * @param str The input string to validate as a URI. - * @return {@code true} if the input string is a URI, {@code false} otherwise. */ - public static boolean isUri(String str) { + static boolean isUri(String str) { return new Uri(str).uri(); } @@ -630,11 +606,8 @@ public static boolean isUri(String str) { * *

    URI, URI Reference, and Relative Reference are defined in the internet standard RFC 3986. * Zone Identifiers in IPv6 address literals are supported (RFC 6874). - * - * @param str The input string to validate as a URI Reference. - * @return {@code true} if the input string is a URI Reference, {@code false} otherwise. */ - public static boolean isUriRef(String str) { + static boolean isUriRef(String str) { return new Uri(str).uriReference(); } @@ -654,13 +627,8 @@ public static boolean isUriRef(String str) { * *

    The same principle applies to IPv4 addresses. "192.168.1.0/24" designates the first 24 bits * of the 32-bit IPv4 as the network prefix. - * - * @param str The input string to validate as an IP with prefix length. - * @param version The version of the address to validate. 0 means either 4 or 6. - * @param strict Whether the host portion must be all zeros. - * @return {@code true} if the input string is a valid IP with prefix length, {@code false} */ - public static boolean isIpPrefix(String str, long version, boolean strict) { + static boolean isIpPrefix(String str, long version, boolean strict) { if (version == 6L) { Ipv6 ip = new Ipv6(str); return ip.addressPrefix() && (!strict || ip.isPrefixOnly()); diff --git a/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java b/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java similarity index 97% rename from src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java rename to src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java index 36de71f1..f2beb579 100644 --- a/src/main/java/build/buf/protovalidate/rules/EnumRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.validate.EnumRules; import build.buf.validate.FieldRules; import com.google.protobuf.Descriptors.EnumValueDescriptor; diff --git a/src/main/java/build/buf/protovalidate/Evaluator.java b/src/main/java/build/buf/protovalidate/Evaluator.java index 4770d483..695623d4 100644 --- a/src/main/java/build/buf/protovalidate/Evaluator.java +++ b/src/main/java/build/buf/protovalidate/Evaluator.java @@ -20,12 +20,8 @@ /** * {@link Evaluator} defines a validation evaluator. evaluator implementations may elide type * checking of the passed in value, as the types have been guaranteed during the build phase. - * - *

    Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can implement - * it; not part of the supported public API. */ -@Internal -public interface Evaluator { +interface Evaluator { /** * Tautology returns true if the evaluator always succeeds. * diff --git a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java index bfe7b743..37c78cc1 100644 --- a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java +++ b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java @@ -15,7 +15,6 @@ package build.buf.protovalidate; import build.buf.protovalidate.exceptions.CompilationException; -import build.buf.protovalidate.rules.Rules; import build.buf.validate.FieldPath; import build.buf.validate.FieldPathElement; import build.buf.validate.FieldRules; diff --git a/src/main/java/build/buf/protovalidate/FieldPathUtils.java b/src/main/java/build/buf/protovalidate/FieldPathUtils.java index 77d7c730..4d34cfd3 100644 --- a/src/main/java/build/buf/protovalidate/FieldPathUtils.java +++ b/src/main/java/build/buf/protovalidate/FieldPathUtils.java @@ -20,14 +20,8 @@ import java.util.List; import org.jspecify.annotations.Nullable; -/** - * Utility class for manipulating error paths in violations. - * - *

    Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can build and - * prepend field path elements; not part of the supported public API. - */ -@Internal -public final class FieldPathUtils { +/** Utility class for manipulating error paths in violations. */ +final class FieldPathUtils { private FieldPathUtils() {} /** @@ -36,7 +30,7 @@ private FieldPathUtils() {} * @param fieldPath A field path to convert to a string. * @return The string representation of the provided field path. */ - public static String fieldPathString(FieldPath fieldPath) { + static String fieldPathString(FieldPath fieldPath) { StringBuilder builder = new StringBuilder(); for (FieldPathElement element : fieldPath.getElementsList()) { if (builder.length() > 0) { @@ -84,7 +78,7 @@ public static String fieldPathString(FieldPath fieldPath) { * @param fieldDescriptor The field descriptor to generate a field path element for. * @return The field path element that corresponds to the provided field descriptor. */ - public static FieldPathElement fieldPathElement(Descriptors.FieldDescriptor fieldDescriptor) { + static FieldPathElement fieldPathElement(Descriptors.FieldDescriptor fieldDescriptor) { String name; if (fieldDescriptor.isExtension()) { name = "[" + fieldDescriptor.getFullName() + "]"; @@ -106,7 +100,7 @@ public static FieldPathElement fieldPathElement(Descriptors.FieldDescriptor fiel * @param rulePathElements Rule path elements to prepend. * @return For convenience, the list of violations passed into the violations parameter. */ - public static List updatePaths( + static List updatePaths( List violations, @Nullable FieldPathElement fieldPathElement, List rulePathElements) { diff --git a/src/main/java/build/buf/protovalidate/Internal.java b/src/main/java/build/buf/protovalidate/Internal.java deleted file mode 100644 index 7109d586..00000000 --- a/src/main/java/build/buf/protovalidate/Internal.java +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2023-2026 Buf Technologies, Inc. -// -// 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 build.buf.protovalidate; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks types and members that are public for the protovalidate-java codebase's own use (e.g. - * cross-package references between {@code build.buf.protovalidate} and its sub-packages) but are - * not part of the supported public API. Code outside this library must not depend on these — they - * may change or disappear at any time without notice. - */ -@Retention(RetentionPolicy.SOURCE) -@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) -public @interface Internal {} diff --git a/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java b/src/main/java/build/buf/protovalidate/MapRulesEvaluator.java similarity index 96% rename from src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java rename to src/main/java/build/buf/protovalidate/MapRulesEvaluator.java index 75e6dfa3..39455b9e 100644 --- a/src/main/java/build/buf/protovalidate/rules/MapRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/MapRulesEvaluator.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; import build.buf.validate.MapRules; import com.google.protobuf.Descriptors.FieldDescriptor; diff --git a/src/main/java/build/buf/protovalidate/rules/NativeViolations.java b/src/main/java/build/buf/protovalidate/NativeViolations.java similarity index 96% rename from src/main/java/build/buf/protovalidate/rules/NativeViolations.java rename to src/main/java/build/buf/protovalidate/NativeViolations.java index c094bfaf..da345b8f 100644 --- a/src/main/java/build/buf/protovalidate/rules/NativeViolations.java +++ b/src/main/java/build/buf/protovalidate/NativeViolations.java @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import org.jspecify.annotations.Nullable; /** diff --git a/src/main/java/build/buf/protovalidate/rules/NumericDescriptors.java b/src/main/java/build/buf/protovalidate/NumericDescriptors.java similarity index 99% rename from src/main/java/build/buf/protovalidate/rules/NumericDescriptors.java rename to src/main/java/build/buf/protovalidate/NumericDescriptors.java index 2dd8e488..b5487038 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericDescriptors.java +++ b/src/main/java/build/buf/protovalidate/NumericDescriptors.java @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import com.google.protobuf.Descriptors.Descriptor; import com.google.protobuf.Descriptors.FieldDescriptor; diff --git a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java b/src/main/java/build/buf/protovalidate/NumericRulesEvaluator.java similarity index 98% rename from src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java rename to src/main/java/build/buf/protovalidate/NumericRulesEvaluator.java index 4daf5958..71f92739 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/NumericRulesEvaluator.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Message; diff --git a/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java similarity index 99% rename from src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java rename to src/main/java/build/buf/protovalidate/NumericTypeConfig.java index 464722c9..ee8866e6 100644 --- a/src/main/java/build/buf/protovalidate/rules/NumericTypeConfig.java +++ b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import build.buf.validate.DoubleRules; import build.buf.validate.FieldRules; diff --git a/src/main/java/build/buf/protovalidate/ObjectValue.java b/src/main/java/build/buf/protovalidate/ObjectValue.java index a4101c17..9a53574a 100644 --- a/src/main/java/build/buf/protovalidate/ObjectValue.java +++ b/src/main/java/build/buf/protovalidate/ObjectValue.java @@ -24,15 +24,8 @@ import java.util.Map; import org.jspecify.annotations.Nullable; -/** - * The {@link Value} type that contains a field descriptor and its value. - * - *

    Public so that {@code WrappedValueEvaluator} in {@code build.buf.protovalidate.rules} can - * construct one when unwrapping a {@code google.protobuf.*Value} field; not part of the supported - * public API. - */ -@Internal -public final class ObjectValue implements Value { +/** The {@link Value} type that contains a field descriptor and its value. */ +final class ObjectValue implements Value { /** * {@link com.google.protobuf.Descriptors.FieldDescriptor} is the field descriptor for the value. @@ -48,7 +41,7 @@ public final class ObjectValue implements Value { * @param fieldDescriptor The field descriptor for the value. * @param value The value associated with the field descriptor. */ - public ObjectValue(Descriptors.FieldDescriptor fieldDescriptor, Object value) { + ObjectValue(Descriptors.FieldDescriptor fieldDescriptor, Object value) { this.fieldDescriptor = fieldDescriptor; this.value = value; } diff --git a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java b/src/main/java/build/buf/protovalidate/RepeatedRulesEvaluator.java similarity index 97% rename from src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java rename to src/main/java/build/buf/protovalidate/RepeatedRulesEvaluator.java index c753e792..83399bb3 100644 --- a/src/main/java/build/buf/protovalidate/rules/RepeatedRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/RepeatedRulesEvaluator.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; import build.buf.validate.RepeatedRules; import com.google.protobuf.Descriptors.FieldDescriptor; diff --git a/src/main/java/build/buf/protovalidate/rules/RuleBase.java b/src/main/java/build/buf/protovalidate/RuleBase.java similarity index 96% rename from src/main/java/build/buf/protovalidate/rules/RuleBase.java rename to src/main/java/build/buf/protovalidate/RuleBase.java index 499e5d4d..1abfd2bf 100644 --- a/src/main/java/build/buf/protovalidate/rules/RuleBase.java +++ b/src/main/java/build/buf/protovalidate/RuleBase.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.FieldPathUtils; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.ValueEvaluator; import build.buf.validate.FieldPath; import build.buf.validate.FieldPathElement; import com.google.protobuf.Descriptors.FieldDescriptor; diff --git a/src/main/java/build/buf/protovalidate/rules/RuleSite.java b/src/main/java/build/buf/protovalidate/RuleSite.java similarity index 97% rename from src/main/java/build/buf/protovalidate/rules/RuleSite.java rename to src/main/java/build/buf/protovalidate/RuleSite.java index 405c4624..aa4a81a8 100644 --- a/src/main/java/build/buf/protovalidate/rules/RuleSite.java +++ b/src/main/java/build/buf/protovalidate/RuleSite.java @@ -12,9 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.FieldPathUtils; import build.buf.validate.FieldPathElement; import com.google.protobuf.Descriptors.FieldDescriptor; import java.util.Arrays; diff --git a/src/main/java/build/buf/protovalidate/RuleViolation.java b/src/main/java/build/buf/protovalidate/RuleViolation.java index 66d3af68..454d2f78 100644 --- a/src/main/java/build/buf/protovalidate/RuleViolation.java +++ b/src/main/java/build/buf/protovalidate/RuleViolation.java @@ -27,17 +27,13 @@ /** * {@link RuleViolation} contains all the collected information about an individual rule violation. - * - *

    Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can construct - * violation builders; not part of the supported public API. */ -@Internal -public final class RuleViolation implements Violation { +final class RuleViolation implements Violation { /** Static value to return when there are no violations. */ - public static final List NO_VIOLATIONS = Collections.emptyList(); + static final List NO_VIOLATIONS = Collections.emptyList(); /** {@link FieldValue} represents a Protobuf field value inside a Protobuf message. */ - public static class FieldValue implements Violation.FieldValue { + static class FieldValue implements Violation.FieldValue { private final @Nullable Object value; private final Descriptors.FieldDescriptor descriptor; @@ -47,7 +43,7 @@ public static class FieldValue implements Violation.FieldValue { * @param value Bare Protobuf field value of field. * @param descriptor Field descriptor pertaining to this field. */ - public FieldValue(@Nullable Object value, Descriptors.FieldDescriptor descriptor) { + FieldValue(@Nullable Object value, Descriptors.FieldDescriptor descriptor) { this.value = value; this.descriptor = descriptor; } @@ -58,7 +54,7 @@ public FieldValue(@Nullable Object value, Descriptors.FieldDescriptor descriptor * * @param value A {@link Value} to create this {@link FieldValue} from. */ - public FieldValue(Value value) { + FieldValue(Value value) { this.value = value.value(Object.class); this.descriptor = Objects.requireNonNull(value.fieldDescriptor()); } @@ -79,7 +75,7 @@ public Descriptors.FieldDescriptor getDescriptor() { private final @Nullable FieldValue ruleValue; /** Builds a Violation instance. */ - public static class Builder { + static class Builder { private @Nullable String ruleId; private @Nullable String message; private boolean forKey = false; @@ -94,7 +90,7 @@ public static class Builder { * @param ruleId Rule ID value to use. * @return The builder. */ - public Builder setRuleId(String ruleId) { + Builder setRuleId(String ruleId) { this.ruleId = ruleId; return this; } @@ -105,7 +101,7 @@ public Builder setRuleId(String ruleId) { * @param message Message value to use. * @return The builder. */ - public Builder setMessage(String message) { + Builder setMessage(String message) { this.message = message; return this; } @@ -116,7 +112,7 @@ public Builder setMessage(String message) { * @param forKey If true, signals that the resulting violation is for a map key. * @return The builder. */ - public Builder setForKey(boolean forKey) { + Builder setForKey(boolean forKey) { this.forKey = forKey; return this; } @@ -127,8 +123,7 @@ public Builder setForKey(boolean forKey) { * @param fieldPathElements Field path elements to add. * @return The builder. */ - public Builder addAllFieldPathElements( - Collection fieldPathElements) { + Builder addAllFieldPathElements(Collection fieldPathElements) { this.fieldPath.addAll(fieldPathElements); return this; } @@ -139,7 +134,7 @@ public Builder addAllFieldPathElements( * @param fieldPathElement A field path element to add to the beginning of the field path. * @return The builder. */ - public Builder addFirstFieldPathElement(@Nullable FieldPathElement fieldPathElement) { + Builder addFirstFieldPathElement(@Nullable FieldPathElement fieldPathElement) { if (fieldPathElement != null) { fieldPath.addFirst(fieldPathElement); } @@ -152,7 +147,7 @@ public Builder addFirstFieldPathElement(@Nullable FieldPathElement fieldPathElem * @param rulePathElements Field path elements to add. * @return The builder. */ - public Builder addAllRulePathElements(Collection rulePathElements) { + Builder addAllRulePathElements(Collection rulePathElements) { rulePath.addAll(rulePathElements); return this; } @@ -163,7 +158,7 @@ public Builder addAllRulePathElements(Collection rul * @param rulePathElements A field path element to add to the beginning of the rule path. * @return The builder. */ - public Builder addFirstRulePathElement(FieldPathElement rulePathElements) { + Builder addFirstRulePathElement(FieldPathElement rulePathElements) { rulePath.addFirst(rulePathElements); return this; } @@ -174,7 +169,7 @@ public Builder addFirstRulePathElement(FieldPathElement rulePathElements) { * @param fieldValue The field value corresponding to this violation. * @return The builder. */ - public Builder setFieldValue(@Nullable FieldValue fieldValue) { + Builder setFieldValue(@Nullable FieldValue fieldValue) { this.fieldValue = fieldValue; return this; } @@ -185,7 +180,7 @@ public Builder setFieldValue(@Nullable FieldValue fieldValue) { * @param ruleValue The rule value corresponding to this violation. * @return The builder. */ - public Builder setRuleValue(@Nullable FieldValue ruleValue) { + Builder setRuleValue(@Nullable FieldValue ruleValue) { this.ruleValue = ruleValue; return this; } @@ -195,7 +190,7 @@ public Builder setRuleValue(@Nullable FieldValue ruleValue) { * * @return A Violation instance. */ - public RuleViolation build() { + RuleViolation build() { build.buf.validate.Violation.Builder protoBuilder = build.buf.validate.Violation.newBuilder(); if (ruleId != null) { protoBuilder.setRuleId(ruleId); @@ -223,7 +218,7 @@ private Builder() {} * * @return A new, empty {@link Builder}. */ - public static Builder newBuilder() { + static Builder newBuilder() { return new Builder(); } diff --git a/src/main/java/build/buf/protovalidate/rules/Rules.java b/src/main/java/build/buf/protovalidate/Rules.java similarity index 97% rename from src/main/java/build/buf/protovalidate/rules/Rules.java rename to src/main/java/build/buf/protovalidate/Rules.java index 33afb2e8..6bf98e6b 100644 --- a/src/main/java/build/buf/protovalidate/rules/Rules.java +++ b/src/main/java/build/buf/protovalidate/Rules.java @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.Internal; -import build.buf.protovalidate.ValueEvaluator; import build.buf.validate.FieldRules; import com.google.protobuf.Descriptors.FieldDescriptor; import org.jspecify.annotations.Nullable; @@ -31,7 +28,6 @@ * that this codebase hasn't yet implemented natively, the rule remains on the residual {@code * FieldRules} and CEL enforces it. Native rules are an optimization, not a replacement. */ -@Internal public final class Rules { private Rules() {} diff --git a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java similarity index 99% rename from src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java rename to src/main/java/build/buf/protovalidate/StringRulesEvaluator.java index 9231b542..5088d097 100644 --- a/src/main/java/build/buf/protovalidate/rules/StringRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java @@ -12,12 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.CustomOverload; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.validate.FieldRules; import build.buf.validate.KnownRegex; import build.buf.validate.StringRules; diff --git a/src/main/java/build/buf/protovalidate/Value.java b/src/main/java/build/buf/protovalidate/Value.java index 855db30d..a2bb96b2 100644 --- a/src/main/java/build/buf/protovalidate/Value.java +++ b/src/main/java/build/buf/protovalidate/Value.java @@ -23,12 +23,8 @@ /** * {@link Value} is a wrapper around a protobuf value that provides helper methods for accessing the * value. - * - *

    Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can consume it; - * not part of the supported public API. */ -@Internal -public interface Value { +interface Value { /** * Get the field descriptor that corresponds to the underlying Value, if it is a message field. * diff --git a/src/main/java/build/buf/protovalidate/ValueEvaluator.java b/src/main/java/build/buf/protovalidate/ValueEvaluator.java index bfbbafe5..f90ea338 100644 --- a/src/main/java/build/buf/protovalidate/ValueEvaluator.java +++ b/src/main/java/build/buf/protovalidate/ValueEvaluator.java @@ -30,7 +30,6 @@ * constructed with descriptor/nested-rule context from a {@link ValueEvaluator}; not part of the * supported public API. */ -@Internal public final class ValueEvaluator implements Evaluator { /** The {@link Descriptors.FieldDescriptor} targeted by this evaluator */ private final Descriptors.@Nullable FieldDescriptor descriptor; diff --git a/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java b/src/main/java/build/buf/protovalidate/WrappedValueEvaluator.java similarity index 93% rename from src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java rename to src/main/java/build/buf/protovalidate/WrappedValueEvaluator.java index de2b64f7..6cba9a24 100644 --- a/src/main/java/build/buf/protovalidate/rules/WrappedValueEvaluator.java +++ b/src/main/java/build/buf/protovalidate/WrappedValueEvaluator.java @@ -12,12 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; -import build.buf.protovalidate.Evaluator; -import build.buf.protovalidate.ObjectValue; -import build.buf.protovalidate.RuleViolation; -import build.buf.protovalidate.Value; import build.buf.protovalidate.exceptions.ExecutionException; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.Message; diff --git a/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java similarity index 95% rename from src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java rename to src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java index dc5e7775..0c7f103d 100644 --- a/src/test/java/build/buf/protovalidate/rules/BoolRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java @@ -12,15 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; -import build.buf.protovalidate.Violation; import build.buf.protovalidate.exceptions.ValidationException; import build.buf.validate.BoolRules; import com.example.noimports.validationtest.ExampleBoolConst; diff --git a/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java similarity index 95% rename from src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java rename to src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java index 3d809437..f65963aa 100644 --- a/src/test/java/build/buf/protovalidate/rules/BytesRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java @@ -12,16 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; -import build.buf.protovalidate.Violation; import build.buf.protovalidate.exceptions.ExecutionException; import build.buf.protovalidate.exceptions.ValidationException; import build.buf.validate.BytesRules; diff --git a/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java similarity index 93% rename from src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java rename to src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java index 4ecef203..29c876a4 100644 --- a/src/test/java/build/buf/protovalidate/rules/EnumRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java @@ -12,15 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; -import build.buf.protovalidate.Violation; import build.buf.protovalidate.exceptions.ValidationException; import build.buf.validate.EnumRules; import com.example.noimports.validationtest.ExampleColor; diff --git a/src/test/java/build/buf/protovalidate/rules/FailFastTest.java b/src/test/java/build/buf/protovalidate/FailFastTest.java similarity index 92% rename from src/test/java/build/buf/protovalidate/rules/FailFastTest.java rename to src/test/java/build/buf/protovalidate/FailFastTest.java index 3c0f9a75..96749ab8 100644 --- a/src/test/java/build/buf/protovalidate/rules/FailFastTest.java +++ b/src/test/java/build/buf/protovalidate/FailFastTest.java @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.exceptions.ValidationException; import com.example.noimports.validationtest.BytesMultiRule; import com.example.noimports.validationtest.Int32MultiRule; diff --git a/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java b/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java similarity index 97% rename from src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java rename to src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java index 8d71d02d..0446de93 100644 --- a/src/test/java/build/buf/protovalidate/rules/FloatBugConfirmationTest.java +++ b/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.exceptions.ValidationException; import com.example.imports.validationtest.FloatDoubleNaNNegZero; import com.example.noimports.validationtest.ExampleDoubleConstNegZero; diff --git a/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java b/src/test/java/build/buf/protovalidate/NotInRulesTest.java similarity index 94% rename from src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java rename to src/test/java/build/buf/protovalidate/NotInRulesTest.java index 28423316..ecee818d 100644 --- a/src/test/java/build/buf/protovalidate/rules/NotInRulesTest.java +++ b/src/test/java/build/buf/protovalidate/NotInRulesTest.java @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.exceptions.ValidationException; import com.example.noimports.validationtest.BytesNotIn; import com.example.noimports.validationtest.EnumNotIn; diff --git a/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java similarity index 96% rename from src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java rename to src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java index da1f4fea..0642a13d 100644 --- a/src/test/java/build/buf/protovalidate/rules/NumericRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java @@ -12,15 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; -import build.buf.protovalidate.Violation; import build.buf.protovalidate.exceptions.ValidationException; import build.buf.validate.DoubleRules; import build.buf.validate.Int32Rules; diff --git a/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java similarity index 95% rename from src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java rename to src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java index f4dd8cfe..4f91c278 100644 --- a/src/test/java/build/buf/protovalidate/rules/RepeatedAndMapRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.exceptions.ValidationException; import com.example.noimports.validationtest.ExampleMapMinMax; import com.example.noimports.validationtest.ExampleRepeatedMinMax; diff --git a/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java b/src/test/java/build/buf/protovalidate/ResidualClearingTest.java similarity index 93% rename from src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java rename to src/test/java/build/buf/protovalidate/ResidualClearingTest.java index 76e5e99c..93a42eaa 100644 --- a/src/test/java/build/buf/protovalidate/rules/ResidualClearingTest.java +++ b/src/test/java/build/buf/protovalidate/ResidualClearingTest.java @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.exceptions.ValidationException; import com.example.noimports.validationtest.ExampleBoolConst; import com.example.noimports.validationtest.ExampleBytesConst; diff --git a/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java similarity index 95% rename from src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java rename to src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java index 88efede9..0b3989d8 100644 --- a/src/test/java/build/buf/protovalidate/rules/StringRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java @@ -12,15 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; -import build.buf.protovalidate.Violation; import build.buf.protovalidate.exceptions.ValidationException; import build.buf.validate.StringRules; import com.example.noimports.validationtest.ExampleStringConst; diff --git a/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java b/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java similarity index 95% rename from src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java rename to src/test/java/build/buf/protovalidate/WellKnownRegexTest.java index 948abe27..4cf759f1 100644 --- a/src/test/java/build/buf/protovalidate/rules/WellKnownRegexTest.java +++ b/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.exceptions.ValidationException; import com.example.noimports.validationtest.HttpHeaderName; import com.example.noimports.validationtest.HttpHeaderNameLoose; diff --git a/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java b/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java similarity index 94% rename from src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java rename to src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java index 0c6d2e99..5e94375d 100644 --- a/src/test/java/build/buf/protovalidate/rules/WrappedValueEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -package build.buf.protovalidate.rules; +package build.buf.protovalidate; import static org.assertj.core.api.Assertions.assertThat; -import build.buf.protovalidate.Config; -import build.buf.protovalidate.ValidationResult; -import build.buf.protovalidate.Validator; -import build.buf.protovalidate.ValidatorFactory; import build.buf.protovalidate.exceptions.ValidationException; import com.example.noimports.validationtest.Int64WrapperConst; import com.example.noimports.validationtest.StringWrapperLen; From 11c77c155ff1235c6c97003536bf2b796493b981 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 11:11:15 -0400 Subject: [PATCH 23/31] remove setDisableNativeRules and use a boolean flag on setEnableNativeRules --- README.md | 2 +- .../benchmarks/EvaluatorBuildBenchmark.java | 4 ++-- .../benchmarks/ValidationBenchmark.java | 4 ++-- .../buf/protovalidate/conformance/Main.java | 4 ++-- .../protovalidate/NativeRulesParityTest.java | 4 ++-- .../java/build/buf/protovalidate/Config.java | 22 ++++++------------- .../protovalidate/BoolRulesEvaluatorTest.java | 4 ++-- .../BytesRulesEvaluatorTest.java | 4 ++-- .../protovalidate/EnumRulesEvaluatorTest.java | 4 ++-- .../build/buf/protovalidate/FailFastTest.java | 2 +- .../FloatBugConfirmationTest.java | 4 ++-- .../buf/protovalidate/NotInRulesTest.java | 2 +- .../NumericRulesEvaluatorTest.java | 4 ++-- .../RepeatedAndMapRulesEvaluatorTest.java | 4 ++-- .../protovalidate/ResidualClearingTest.java | 2 +- .../StringRulesEvaluatorTest.java | 4 ++-- .../buf/protovalidate/WellKnownRegexTest.java | 2 +- .../WrappedValueEvaluatorTest.java | 2 +- 18 files changed, 35 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 281f3f6e..3ab113b6 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ The standard rules can be evaluated either through CEL or through native Java co Native rules are **opt-out**. Disable them by configuring the validator: ```java -Config config = Config.newBuilder().setDisableNativeRules().build(); +Config config = Config.newBuilder().setEnableNativeRules(false).build(); Validator validator = ValidatorFactory.newBuilder().withConfig(config).build(); ``` diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java index 44b16f0d..5f5402fb 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java @@ -52,9 +52,9 @@ public class EvaluatorBuildBenchmark { @Setup public void setup() { if (enableNativeRules) { - config = Config.newBuilder().setEnableNativeRules().build(); + config = Config.newBuilder().setEnableNativeRules(true).build(); } else { - config = Config.newBuilder().setDisableNativeRules().build(); + config = Config.newBuilder().setEnableNativeRules(false).build(); } benchComplexSchema = BenchComplexSchema.getDefaultInstance(); benchGT = BenchGT.getDefaultInstance(); diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index b280047e..160d4218 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -131,9 +131,9 @@ public class ValidationBenchmark { public void setup() throws ValidationException { Config config; if (enableNativeRules) { - config = Config.newBuilder().setEnableNativeRules().build(); + config = Config.newBuilder().setEnableNativeRules(true).build(); } else { - config = Config.newBuilder().setDisableNativeRules().build(); + config = Config.newBuilder().setEnableNativeRules(false).build(); } validator = ValidatorFactory.newBuilder().withConfig(config).build(); diff --git a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java index 999f3a24..42962dde 100644 --- a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java +++ b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java @@ -70,9 +70,9 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) { if (envFlag != null) { boolean enableNativeRules = Boolean.parseBoolean(envFlag); if (enableNativeRules) { - cfgBuilder.setEnableNativeRules(); + cfgBuilder.setEnableNativeRules(true); } else { - cfgBuilder.setDisableNativeRules(); + cfgBuilder.setEnableNativeRules(false); } } Config cfg = cfgBuilder.build(); diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index 64f49ce1..88f6266c 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -60,11 +60,11 @@ class NativeRulesParityTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) .build(); private final Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); /** diff --git a/src/main/java/build/buf/protovalidate/Config.java b/src/main/java/build/buf/protovalidate/Config.java index 589cb36b..e1282321 100644 --- a/src/main/java/build/buf/protovalidate/Config.java +++ b/src/main/java/build/buf/protovalidate/Config.java @@ -92,7 +92,7 @@ public boolean isAllowingUnknownFields() { * *

    When true, standard rules with a native Java implementation bypass CEL evaluation. When * false, all rules go through CEL. Defaults to true; applications opt out by calling {@link - * Builder#setDisableNativeRules() setDisableNativeRules()}. + * Builder#setEnableNativeRules(boolean) setEnableNativeRules(false)}. * * @return true if native rules are enabled. */ @@ -176,23 +176,15 @@ public Builder setAllowUnknownFields(boolean allowUnknownFields) { } /** - * Enables native (non-CEL) rule evaluators. Forward-compatible: any rule not yet implemented - * natively continues to be enforced via CEL regardless of this setting. + * Enables or disables native (non-CEL) rule evaluators. Native rules are enabled by default. + * Forward-compatible: any rule not yet implemented natively continues to be enforced via CEL + * regardless of this setting. * + * @param enableNativeRules whether to enable native rules * @return this builder */ - public Builder setEnableNativeRules() { - this.enableNativeRules = true; - return this; - } - - /** - * Disables native (non-CEL) rule evaluators. - * - * @return this builder - */ - public Builder setDisableNativeRules() { - this.enableNativeRules = false; + public Builder setEnableNativeRules(boolean enableNativeRules) { + this.enableNativeRules = enableNativeRules; return this; } diff --git a/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java index 0c7f103d..ddb32fa7 100644 --- a/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/BoolRulesEvaluatorTest.java @@ -31,7 +31,7 @@ class BoolRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules().build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -88,7 +88,7 @@ void nativeAndCelProducePartiallyEqualViolations() throws ValidationException { ValidationResult nativeResult = nativeValidator().validate(msg); Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); ValidationResult celResult = celValidator.validate(msg); diff --git a/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java index f65963aa..eb8efbb2 100644 --- a/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/BytesRulesEvaluatorTest.java @@ -31,7 +31,7 @@ class BytesRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules().build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -115,7 +115,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java index 29c876a4..c3aad8e9 100644 --- a/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/EnumRulesEvaluatorTest.java @@ -28,7 +28,7 @@ class EnumRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules().build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -79,7 +79,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/FailFastTest.java b/src/test/java/build/buf/protovalidate/FailFastTest.java index 96749ab8..292ec699 100644 --- a/src/test/java/build/buf/protovalidate/FailFastTest.java +++ b/src/test/java/build/buf/protovalidate/FailFastTest.java @@ -32,7 +32,7 @@ class FailFastTest { private static Validator validator(boolean failFast) { - Config config = Config.newBuilder().setEnableNativeRules().setFailFast(failFast).build(); + Config config = Config.newBuilder().setEnableNativeRules(true).setFailFast(failFast).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } diff --git a/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java b/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java index 0446de93..d02f9d76 100644 --- a/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java +++ b/src/test/java/build/buf/protovalidate/FloatBugConfirmationTest.java @@ -51,11 +51,11 @@ class FloatBugConfirmationTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) .build(); private final Validator celValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); // --- B1: floatFormatter renders -0.0 as "0", losing the sign -------------------------------- diff --git a/src/test/java/build/buf/protovalidate/NotInRulesTest.java b/src/test/java/build/buf/protovalidate/NotInRulesTest.java index ecee818d..d1221952 100644 --- a/src/test/java/build/buf/protovalidate/NotInRulesTest.java +++ b/src/test/java/build/buf/protovalidate/NotInRulesTest.java @@ -35,7 +35,7 @@ class NotInRulesTest { private final Validator validator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) .build(); @Test diff --git a/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java index 0642a13d..4be6f76d 100644 --- a/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/NumericRulesEvaluatorTest.java @@ -44,7 +44,7 @@ class NumericRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules().build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -155,7 +155,7 @@ void nativeAndCelProduceEqualViolationProtos() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) diff --git a/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java index 4f91c278..78a1d2ae 100644 --- a/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/RepeatedAndMapRulesEvaluatorTest.java @@ -26,7 +26,7 @@ class RepeatedAndMapRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules().build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -113,7 +113,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/ResidualClearingTest.java b/src/test/java/build/buf/protovalidate/ResidualClearingTest.java index 93a42eaa..524cbcd5 100644 --- a/src/test/java/build/buf/protovalidate/ResidualClearingTest.java +++ b/src/test/java/build/buf/protovalidate/ResidualClearingTest.java @@ -37,7 +37,7 @@ class ResidualClearingTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) .build(); @Test diff --git a/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java index 0b3989d8..7a835580 100644 --- a/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java @@ -29,7 +29,7 @@ class StringRulesEvaluatorTest { private static Validator nativeValidator() { - Config config = Config.newBuilder().setEnableNativeRules().build(); + Config config = Config.newBuilder().setEnableNativeRules(true).build(); return ValidatorFactory.newBuilder().withConfig(config).build(); } @@ -125,7 +125,7 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { Validator nativeV = nativeValidator(); Validator celV = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setDisableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(false).build()) .build(); assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); diff --git a/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java b/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java index 4cf759f1..e4aafdca 100644 --- a/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java +++ b/src/test/java/build/buf/protovalidate/WellKnownRegexTest.java @@ -30,7 +30,7 @@ class WellKnownRegexTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) .build(); @Test diff --git a/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java b/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java index 5e94375d..22a4f182 100644 --- a/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/WrappedValueEvaluatorTest.java @@ -32,7 +32,7 @@ class WrappedValueEvaluatorTest { private final Validator nativeValidator = ValidatorFactory.newBuilder() - .withConfig(Config.newBuilder().setEnableNativeRules().build()) + .withConfig(Config.newBuilder().setEnableNativeRules(true).build()) .build(); @Test From a554ada52a7cb73cd04375776a22f97dcc223079 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 13:41:36 -0400 Subject: [PATCH 24/31] remove unneeded uses of Violation interface when RuleViolation will suffice. RuleViolation is the only implementation of Violation; the interface should be removed entirely. --- .../protovalidate/NativeRulesParityTest.java | 5 ++-- .../buf/protovalidate/ValidationResult.java | 9 ++++--- .../buf/protovalidate/ValidatorImpl.java | 9 +++---- .../protovalidate/ValidationResultTest.java | 27 ++++++------------- 4 files changed, 19 insertions(+), 31 deletions(-) diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index 88f6266c..eb53945e 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; import build.buf.protovalidate.exceptions.ValidationException; +import build.buf.validate.Violation; import build.buf.validate.conformance.cases.AnEnum; import build.buf.validate.conformance.cases.BoolConstTrue; import build.buf.validate.conformance.cases.BytesContains; @@ -133,7 +134,7 @@ void parityForFixture(String name, Message msg) throws ValidationException { .isEqualTo(toProtoList(celResult)); } - private static List toProtoList(ValidationResult result) { - return result.getViolations().stream().map(Violation::toProto).collect(Collectors.toList()); + private static List toProtoList(ValidationResult result) { + return result.getViolations().stream().map(RuleViolation::toProto).collect(Collectors.toList()); } } diff --git a/src/main/java/build/buf/protovalidate/ValidationResult.java b/src/main/java/build/buf/protovalidate/ValidationResult.java index 5373b5a8..940b02cd 100644 --- a/src/main/java/build/buf/protovalidate/ValidationResult.java +++ b/src/main/java/build/buf/protovalidate/ValidationResult.java @@ -26,9 +26,10 @@ public class ValidationResult { /** - * violations is a list of {@link Violation} that occurred during the validations of a message. + * violations is a list of {@link RuleViolation} that occurred during the validations of a + * message. */ - private final List violations; + private final List violations; /** A violation result with an empty violation list. */ public static final ValidationResult EMPTY = new ValidationResult(Collections.emptyList()); @@ -38,7 +39,7 @@ public class ValidationResult { * * @param violations violation list for the result. */ - public ValidationResult(List violations) { + public ValidationResult(List violations) { this.violations = violations; } @@ -56,7 +57,7 @@ public boolean isSuccess() { * * @return the violation list. */ - public List getViolations() { + public List getViolations() { return violations; } diff --git a/src/main/java/build/buf/protovalidate/ValidatorImpl.java b/src/main/java/build/buf/protovalidate/ValidatorImpl.java index 39613c9f..81d8cadf 100644 --- a/src/main/java/build/buf/protovalidate/ValidatorImpl.java +++ b/src/main/java/build/buf/protovalidate/ValidatorImpl.java @@ -18,8 +18,8 @@ import build.buf.protovalidate.exceptions.ValidationException; import com.google.protobuf.Descriptors.Descriptor; import com.google.protobuf.Message; -import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; final class ValidatorImpl implements Validator { /** evaluatorBuilder is the builder used to construct the evaluator for a given message. */ @@ -54,10 +54,7 @@ public ValidationResult validate(Message msg) throws ValidationException { if (result.isEmpty()) { return ValidationResult.EMPTY; } - List violations = new ArrayList<>(result.size()); - for (RuleViolation.Builder builder : result) { - violations.add(builder.build()); - } - return new ValidationResult(violations); + return new ValidationResult( + result.stream().map(RuleViolation.Builder::build).collect(Collectors.toList())); } } diff --git a/src/test/java/build/buf/protovalidate/ValidationResultTest.java b/src/test/java/build/buf/protovalidate/ValidationResultTest.java index 24490929..62b9601b 100644 --- a/src/test/java/build/buf/protovalidate/ValidationResultTest.java +++ b/src/test/java/build/buf/protovalidate/ValidationResultTest.java @@ -18,6 +18,8 @@ import build.buf.validate.FieldPathElement; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Test; @@ -25,7 +27,7 @@ class ValidationResultTest { @Test void testToStringNoViolations() { - List violations = new ArrayList<>(); + List violations = new ArrayList<>(); ValidationResult result = new ValidationResult(violations); assertThat(result.toString()).isEqualTo("Validation OK"); @@ -43,9 +45,7 @@ void testToStringSingleViolation() { .setMessage("must equal 42") .addFirstFieldPathElement(elem) .build(); - List violations = new ArrayList<>(); - violations.add(violation); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Collections.singletonList(violation)); assertThat(result.toString()) .isEqualTo("Validation error:\n - test_field_name: must equal 42 [int32.const]"); @@ -69,10 +69,7 @@ void testToStringMultipleViolations() { .setMessage("value is required") .addFirstFieldPathElement(elem) .build(); - List violations = new ArrayList<>(); - violations.add(violation1); - violations.add(violation2); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Arrays.asList(violation1, violation2)); assertThat(result.toString()) .isEqualTo( @@ -86,20 +83,14 @@ void testToStringSingleViolationMultipleFieldPathElements() { FieldPathElement elem2 = FieldPathElement.newBuilder().setFieldNumber(5).setFieldName("nested_name").build(); - List elems = new ArrayList<>(); - elems.add(elem1); - elems.add(elem2); - RuleViolation violation1 = RuleViolation.newBuilder() .setRuleId("int32.const") .setMessage("must equal 42") - .addAllFieldPathElements(elems) + .addAllFieldPathElements(Arrays.asList(elem1, elem2)) .build(); - List violations = new ArrayList<>(); - violations.add(violation1); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Collections.singletonList(violation1)); assertThat(result.toString()) .isEqualTo( @@ -110,9 +101,7 @@ void testToStringSingleViolationMultipleFieldPathElements() { void testToStringSingleViolationNoFieldPathElements() { RuleViolation violation = RuleViolation.newBuilder().setRuleId("int32.const").setMessage("must equal 42").build(); - List violations = new ArrayList<>(); - violations.add(violation); - ValidationResult result = new ValidationResult(violations); + ValidationResult result = new ValidationResult(Collections.singletonList(violation)); assertThat(result.toString()).isEqualTo("Validation error:\n - must equal 42 [int32.const]"); } From 7d7e3d984c155538e49ddb16d04406edb17cf4fa Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 14:32:36 -0400 Subject: [PATCH 25/31] minor code review issues addressed --- .github/workflows/conformance.yaml | 2 +- benchmarks/README.md | 2 +- benchmarks/build.gradle.kts | 8 ++++---- ...pare-params.jq => jmh-compare-native-rules.jq} | 5 +++++ .../build/buf/protovalidate/conformance/Main.java | 3 --- .../buf/protovalidate/EnumRulesEvaluator.java | 6 +++++- .../build/buf/protovalidate/EvaluatorBuilder.java | 15 +++++++++------ 7 files changed, 25 insertions(+), 16 deletions(-) rename benchmarks/{jmh-compare-params.jq => jmh-compare-native-rules.jq} (71%) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index e7aff5bf..898cf55d 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -39,5 +39,5 @@ jobs: uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - name: Test conformance (CEL-only) run: make conformance-cel - - name: Test conformance (native rules enabled — default mode) + - name: Test conformance (default mode) run: make conformance diff --git a/benchmarks/README.md b/benchmarks/README.md index 3b93099b..fe12957e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -61,7 +61,7 @@ Diff them in place: ``` ./gradlew :benchmarks:jmh -./gradlew :benchmarks:jmhCompareParams +./gradlew :benchmarks:jmhCompareNativeRules ``` Output (`before` = CEL, `after` = native; negative delta means native is faster / allocates less): diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 64663306..8186a0d7 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -150,18 +150,18 @@ tasks.register("jmhCompare") { // allocates less. // // Override the input file: -// ./gradlew :benchmarks:jmhCompareParams -Presults=path/to/results.json -tasks.register("jmhCompareParams") { +// ./gradlew :benchmarks:jmhCompareNative -Presults=path/to/results.json +tasks.register("jmhCompareNativeRules") { description = "Diffs enableNativeRules=true vs false from a single JMH results.json." val results = project.findProperty("results")?.toString() ?: jmhResults.get().asFile.absolutePath - val jqScript = file("jmh-compare-params.jq").absolutePath + val jqScript = file("jmh-compare-native-rules.jq").absolutePath commandLine( "bash", "-c", "jq --raw-output --from-file \"\$1\" \"\$2\" | column -t -s \$'\\t'", - "jmh-compare-params", // $0 + "jmh-compare-native-rules", // $0 jqScript, // $1 results, // $2 ) diff --git a/benchmarks/jmh-compare-params.jq b/benchmarks/jmh-compare-native-rules.jq similarity index 71% rename from benchmarks/jmh-compare-params.jq rename to benchmarks/jmh-compare-native-rules.jq index 5645a11f..fcec2004 100644 --- a/benchmarks/jmh-compare-params.jq +++ b/benchmarks/jmh-compare-native-rules.jq @@ -1,3 +1,8 @@ +# this script builds a comparison between runs that used the CEL interpreter for +# protovalidate rule evaluation vs runs that used native Java code for protovalidate +# rule evaluation. The differentiator is the value of the "native" field (true for +# native, false for CEL) and this script groups rows that have the same benchmark +# and metric name, but different values for native. def pct(a; b): if a == null or b == null or b == 0 then "~" else (((a - b) / b * 100) * 10 | round / 10) as $d diff --git a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java index 42962dde..3e169920 100644 --- a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java +++ b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java @@ -61,9 +61,6 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) { TypeRegistry typeRegistry = FileDescriptorUtil.createTypeRegistry(fileDescriptorMap.values()); ExtensionRegistry extensionRegistry = FileDescriptorUtil.createExtensionRegistry(fileDescriptorMap.values()); - // ENABLE_NATIVE_RULES env var lets the conformance runner exercise both rule-evaluation - // modes without code changes. Defaults to whatever Config's default is so a plain - // `gradlew :conformance:test` matches user-facing behavior. String envFlag = System.getenv("ENABLE_NATIVE_RULES"); Config.Builder cfgBuilder = Config.newBuilder().setTypeRegistry(typeRegistry).setExtensionRegistry(extensionRegistry); diff --git a/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java b/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java index f2beb579..ba26aad3 100644 --- a/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/EnumRulesEvaluator.java @@ -158,8 +158,12 @@ private static int enumNumber(Object raw) { if (raw instanceof Integer) { return (Integer) raw; } + // the enum wire format says that enums are encoded as though they are int32s. + // https://protobuf.dev/programming-guides/encoding/ I don't know if the value could + // end up in a Long somehow, so coding defensively around that. If a value out of + // 32-bit int range shows up, Math.toIntExact will throw an exception. if (raw instanceof Long) { - return ((Long) raw).intValue(); + return Math.toIntExact((Long) raw); } throw new IllegalStateException( "unexpected enum value representation: " + raw.getClass().getName()); diff --git a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java index 37c78cc1..00348f4c 100644 --- a/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java +++ b/src/main/java/build/buf/protovalidate/EvaluatorBuilder.java @@ -70,8 +70,12 @@ final class EvaluatorBuilder { * @param config The configuration to use for the evaluation. */ EvaluatorBuilder(Cel cel, Config config) { + this(cel, config, false); + } + + private EvaluatorBuilder(Cel cel, Config config, boolean disableLazy) { this.cel = cel; - this.disableLazy = false; + this.disableLazy = disableLazy; this.enableNativeRules = config.isNativeRulesEnabled(); this.rules = new RuleCache(cel, config); } @@ -81,15 +85,14 @@ final class EvaluatorBuilder { * * @param cel The CEL environment for evaluation. * @param config The configuration to use for the evaluation. + * @param descriptors The descriptors to build evaluators for. Must be non-null. + * @param disableLazy If true, the builder will not cache evaluators for descriptors that are not + * @throws CompilationException If an evaluator can't be built for a descriptor. */ EvaluatorBuilder(Cel cel, Config config, List descriptors, boolean disableLazy) throws CompilationException { + this(cel, config, disableLazy); Objects.requireNonNull(descriptors, "descriptors must not be null"); - this.cel = cel; - this.disableLazy = disableLazy; - this.enableNativeRules = config.isNativeRulesEnabled(); - this.rules = new RuleCache(cel, config); - for (Descriptor descriptor : descriptors) { this.build(descriptor); } From e4f3463d5344888bed524e2906c887e710b51097 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 17:25:28 -0400 Subject: [PATCH 26/31] fix numeric formatting and NaN comparisons. Native is now more correct than CEL (CEL considers NaN equal to itself in a list) --- .../buf/protovalidate/NumericTypeConfig.java | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/main/java/build/buf/protovalidate/NumericTypeConfig.java b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java index ee8866e6..a7960029 100644 --- a/src/main/java/build/buf/protovalidate/NumericTypeConfig.java +++ b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java @@ -28,6 +28,7 @@ import build.buf.validate.UInt32Rules; import build.buf.validate.UInt64Rules; import com.google.protobuf.Descriptors.FieldDescriptor; +import java.math.BigDecimal; import java.util.Comparator; import java.util.function.Function; @@ -255,6 +256,10 @@ private static int floatCompare(Float f1, Float f2) { if (f1 == 0.0f && f2 == 0.0f) { return 0; } + // NaN != Nan, but Java thinks it does. + if (f1.isNaN() && f2.isNaN()) { + return -1; + } return f1.compareTo(f2); } @@ -262,6 +267,11 @@ private static int doubleCompare(Double d1, Double d2) { if (d1 == 0.0 && d2 == 0.0) { return 0; } + // NaN != Nan, but Java thinks it does. + if (d1.isNaN() && d2.isNaN()) { + return -1; + } + return d1.compareTo(d2); } @@ -272,22 +282,43 @@ private static String floatFormatter(Float f) { if (Float.floatToIntBits(f) == FLOAT_NEG_ZERO_BITS) { return "-0"; } - // Whole-number short-circuit: print "5" rather than "5.0" to match Go's %g behavior. - float asInt = f.intValue(); - if (asInt == f) { - return String.valueOf(f.intValue()); + if (f.isNaN()) { + return "NaN"; + } + if (f.isInfinite()) { + if (Math.signum(f) < 0) { + return "-Infinity"; + } + return "Infinity"; } - return String.valueOf(f); + // closest way to get to strconv.FormatFloat(d, 'f', -1, 64) in Go + String out = BigDecimal.valueOf(f).toPlainString(); + // cut off .0 at the end for whole numbers + if (out.endsWith(".0")) { + out = out.substring(0, out.length() - 2); + } + return out; } private static String doubleFormatter(Double d) { if (Double.doubleToLongBits(d) == DOUBLE_NEG_ZERO_BITS) { return "-0"; } - double asInt = d.intValue(); - if (asInt == d) { - return String.valueOf(d.intValue()); + if (d.isNaN()) { + return "NaN"; + } + if (d.isInfinite()) { + if (Math.signum(d) < 0) { + return "-Infinity"; + } + return "Infinity"; + } + // closest way to get to strconv.FormatFloat(d, 'f', -1, 64) in Go + String out = BigDecimal.valueOf(d).toPlainString(); + // cut off .0 at the end for whole numbers + if (out.endsWith(".0")) { + out = out.substring(0, out.length() - 2); } - return String.valueOf(d); + return out; } } From 5539a1a3a833902e99047fde9aa2efbfe5fe1e47 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 17:33:02 -0400 Subject: [PATCH 27/31] fixed README --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 3ab113b6..7a535db8 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Highlights for Java developers include: * A comprehensive RPC quickstart for [Java and gRPC][grpc-java] * A [migration guide for protoc-gen-validate][migration-guide] users -## Native rule evaluators (opt-in) +## Native rule evaluators (opt-out) The standard rules can be evaluated either through CEL or through native Java code. Native evaluation is functionally identical (the conformance suite passes in both modes) but skips CEL compilation and runtime overhead for the rules it covers — a single `validate()` call on a complex message can run an order of magnitude faster and allocate ~10× less. @@ -75,8 +75,6 @@ Config config = Config.newBuilder().setEnableNativeRules(false).build(); Validator validator = ValidatorFactory.newBuilder().withConfig(config).build(); ``` -Native evaluators currently cover bool, all 12 numeric kinds (signed and unsigned int32/int64, float, double, etc.), enum (`const`/`in`/`not_in`; the existing `defined_only` path is unchanged), bytes, string (including all well-known formats), repeated/map list-level rules (`min_items`/`max_items`/`unique`, `min_pairs`/`max_pairs`), and the `google.protobuf.{Bool,Int32,Int64,UInt32,UInt64,Float,Double,String,Bytes}Value` wrapper types — rules on wrapper-typed fields run through the same native evaluators after unwrapping. - Forward compatibility is preserved by a clone-and-clear contract: when protovalidate adds a new rule that this codebase hasn't yet implemented natively, the rule remains on the residual `FieldRules` and CEL enforces it. Native evaluation is an optimization, never a replacement. ## Additional languages and repositories From 33f6c225695801a02b9d31c98c192399f454c756 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 18:23:57 -0400 Subject: [PATCH 28/31] revert public API changes --- .../protovalidate/NativeRulesParityTest.java | 5 ++-- .../java/build/buf/protovalidate/Rules.java | 4 +-- .../buf/protovalidate/ValidationResult.java | 9 +++---- .../buf/protovalidate/ValidatorImpl.java | 9 ++++--- .../buf/protovalidate/ValueEvaluator.java | 27 +++---------------- .../protovalidate/ValidationResultTest.java | 2 +- 6 files changed, 19 insertions(+), 37 deletions(-) diff --git a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java index eb53945e..88f6266c 100644 --- a/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java +++ b/conformance/src/test/java/build/buf/protovalidate/NativeRulesParityTest.java @@ -17,7 +17,6 @@ import static org.assertj.core.api.Assertions.assertThat; import build.buf.protovalidate.exceptions.ValidationException; -import build.buf.validate.Violation; import build.buf.validate.conformance.cases.AnEnum; import build.buf.validate.conformance.cases.BoolConstTrue; import build.buf.validate.conformance.cases.BytesContains; @@ -134,7 +133,7 @@ void parityForFixture(String name, Message msg) throws ValidationException { .isEqualTo(toProtoList(celResult)); } - private static List toProtoList(ValidationResult result) { - return result.getViolations().stream().map(RuleViolation::toProto).collect(Collectors.toList()); + private static List toProtoList(ValidationResult result) { + return result.getViolations().stream().map(Violation::toProto).collect(Collectors.toList()); } } diff --git a/src/main/java/build/buf/protovalidate/Rules.java b/src/main/java/build/buf/protovalidate/Rules.java index 6bf98e6b..9f824bb3 100644 --- a/src/main/java/build/buf/protovalidate/Rules.java +++ b/src/main/java/build/buf/protovalidate/Rules.java @@ -28,7 +28,7 @@ * that this codebase hasn't yet implemented natively, the rule remains on the residual {@code * FieldRules} and CEL enforces it. Native rules are an optimization, not a replacement. */ -public final class Rules { +final class Rules { private Rules() {} /** @@ -46,7 +46,7 @@ private Rules() {} * @return a native {@link Evaluator}, or null if no native evaluator applies (CEL handles * everything) */ - public static @Nullable Evaluator tryBuild( + static @Nullable Evaluator tryBuild( FieldDescriptor fieldDescriptor, FieldRules.Builder rulesBuilder, ValueEvaluator valueEvaluator) { diff --git a/src/main/java/build/buf/protovalidate/ValidationResult.java b/src/main/java/build/buf/protovalidate/ValidationResult.java index 940b02cd..5373b5a8 100644 --- a/src/main/java/build/buf/protovalidate/ValidationResult.java +++ b/src/main/java/build/buf/protovalidate/ValidationResult.java @@ -26,10 +26,9 @@ public class ValidationResult { /** - * violations is a list of {@link RuleViolation} that occurred during the validations of a - * message. + * violations is a list of {@link Violation} that occurred during the validations of a message. */ - private final List violations; + private final List violations; /** A violation result with an empty violation list. */ public static final ValidationResult EMPTY = new ValidationResult(Collections.emptyList()); @@ -39,7 +38,7 @@ public class ValidationResult { * * @param violations violation list for the result. */ - public ValidationResult(List violations) { + public ValidationResult(List violations) { this.violations = violations; } @@ -57,7 +56,7 @@ public boolean isSuccess() { * * @return the violation list. */ - public List getViolations() { + public List getViolations() { return violations; } diff --git a/src/main/java/build/buf/protovalidate/ValidatorImpl.java b/src/main/java/build/buf/protovalidate/ValidatorImpl.java index 81d8cadf..39613c9f 100644 --- a/src/main/java/build/buf/protovalidate/ValidatorImpl.java +++ b/src/main/java/build/buf/protovalidate/ValidatorImpl.java @@ -18,8 +18,8 @@ import build.buf.protovalidate.exceptions.ValidationException; import com.google.protobuf.Descriptors.Descriptor; import com.google.protobuf.Message; +import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; final class ValidatorImpl implements Validator { /** evaluatorBuilder is the builder used to construct the evaluator for a given message. */ @@ -54,7 +54,10 @@ public ValidationResult validate(Message msg) throws ValidationException { if (result.isEmpty()) { return ValidationResult.EMPTY; } - return new ValidationResult( - result.stream().map(RuleViolation.Builder::build).collect(Collectors.toList())); + List violations = new ArrayList<>(result.size()); + for (RuleViolation.Builder builder : result) { + violations.add(builder.build()); + } + return new ValidationResult(violations); } } diff --git a/src/main/java/build/buf/protovalidate/ValueEvaluator.java b/src/main/java/build/buf/protovalidate/ValueEvaluator.java index f90ea338..433da1c4 100644 --- a/src/main/java/build/buf/protovalidate/ValueEvaluator.java +++ b/src/main/java/build/buf/protovalidate/ValueEvaluator.java @@ -25,12 +25,8 @@ /** * {@link ValueEvaluator} performs validation on any concrete value contained within a singular * field, repeated elements, or the keys/values of a map. - * - *

    Public so that native rule evaluators in {@code build.buf.protovalidate.rules} can be - * constructed with descriptor/nested-rule context from a {@link ValueEvaluator}; not part of the - * supported public API. */ -public final class ValueEvaluator implements Evaluator { +final class ValueEvaluator implements Evaluator { /** The {@link Descriptors.FieldDescriptor} targeted by this evaluator */ private final Descriptors.@Nullable FieldDescriptor descriptor; @@ -55,30 +51,15 @@ public final class ValueEvaluator implements Evaluator { this.nestedRule = nestedRule; } - /** - * Returns the {@link Descriptors.FieldDescriptor} targeted by this evaluator. - * - * @return The {@link Descriptors.FieldDescriptor} targeted by this evaluator. - */ - public Descriptors.@Nullable FieldDescriptor getDescriptor() { + Descriptors.@Nullable FieldDescriptor getDescriptor() { return descriptor; } - /** - * Returns the nested rule path that this value evaluator is for. - * - * @return The nested rule path that this value evaluator is for. - */ - public @Nullable FieldPath getNestedRule() { + @Nullable FieldPath getNestedRule() { return nestedRule; } - /** - * Returns true if this value evaluator is for a nested rule. - * - * @return {@code true} if this value evaluator is for a nested rule, {@code false} otherwise. - */ - public boolean hasNestedRule() { + boolean hasNestedRule() { return this.nestedRule != null; } diff --git a/src/test/java/build/buf/protovalidate/ValidationResultTest.java b/src/test/java/build/buf/protovalidate/ValidationResultTest.java index 62b9601b..8ac01491 100644 --- a/src/test/java/build/buf/protovalidate/ValidationResultTest.java +++ b/src/test/java/build/buf/protovalidate/ValidationResultTest.java @@ -27,7 +27,7 @@ class ValidationResultTest { @Test void testToStringNoViolations() { - List violations = new ArrayList<>(); + List violations = new ArrayList<>(); ValidationResult result = new ValidationResult(violations); assertThat(result.toString()).isEqualTo("Validation OK"); From da71616bcfc243bd65c3e21fc841b13a40c2abba Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 18:35:32 -0400 Subject: [PATCH 29/31] address PR comments --- Makefile | 7 ++----- .../protovalidate/benchmarks/EvaluatorBuildBenchmark.java | 6 +----- .../buf/protovalidate/benchmarks/ValidationBenchmark.java | 7 +------ .../java/build/buf/protovalidate/conformance/Main.java | 7 +------ .../java/build/buf/protovalidate/NumericTypeConfig.java | 1 - 5 files changed, 5 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 5f42293f..2aa35c07 100644 --- a/Makefile +++ b/Makefile @@ -28,13 +28,10 @@ checkgenerate: generate ## Checks if `make generate` produces a diff. clean: ## Delete intermediate build artifacts $(GRADLE) clean -.PHONY: conformance-cel -conformance-cel: ## Execute conformance tests with CEL-only rule evaluation. - ENABLE_NATIVE_RULES=false $(GRADLE) conformance:conformance - .PHONY: conformance -conformance: ## Execute conformance tests with native rule evaluators enabled. +conformance: ## Execute conformance tests with native rule evaluators enabled and disabled. ENABLE_NATIVE_RULES=true $(GRADLE) conformance:conformance + ENABLE_NATIVE_RULES=false $(GRADLE) conformance:conformance .PHONY: help help: ## Describe useful make targets diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java index 5f5402fb..8f22a121 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/EvaluatorBuildBenchmark.java @@ -51,11 +51,7 @@ public class EvaluatorBuildBenchmark { @Setup public void setup() { - if (enableNativeRules) { - config = Config.newBuilder().setEnableNativeRules(true).build(); - } else { - config = Config.newBuilder().setEnableNativeRules(false).build(); - } + config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); benchComplexSchema = BenchComplexSchema.getDefaultInstance(); benchGT = BenchGT.getDefaultInstance(); } diff --git a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java index 160d4218..93d34bc9 100644 --- a/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java +++ b/benchmarks/src/jmh/java/build/buf/protovalidate/benchmarks/ValidationBenchmark.java @@ -129,12 +129,7 @@ public class ValidationBenchmark { @Setup public void setup() throws ValidationException { - Config config; - if (enableNativeRules) { - config = Config.newBuilder().setEnableNativeRules(true).build(); - } else { - config = Config.newBuilder().setEnableNativeRules(false).build(); - } + Config config = Config.newBuilder().setEnableNativeRules(enableNativeRules).build(); validator = ValidatorFactory.newBuilder().withConfig(config).build(); diff --git a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java index 3e169920..902de0a6 100644 --- a/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java +++ b/conformance/src/main/java/build/buf/protovalidate/conformance/Main.java @@ -65,12 +65,7 @@ static TestConformanceResponse testConformance(TestConformanceRequest request) { Config.Builder cfgBuilder = Config.newBuilder().setTypeRegistry(typeRegistry).setExtensionRegistry(extensionRegistry); if (envFlag != null) { - boolean enableNativeRules = Boolean.parseBoolean(envFlag); - if (enableNativeRules) { - cfgBuilder.setEnableNativeRules(true); - } else { - cfgBuilder.setEnableNativeRules(false); - } + cfgBuilder.setEnableNativeRules(Boolean.parseBoolean(envFlag)); } Config cfg = cfgBuilder.build(); Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build(); diff --git a/src/main/java/build/buf/protovalidate/NumericTypeConfig.java b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java index a7960029..acf88727 100644 --- a/src/main/java/build/buf/protovalidate/NumericTypeConfig.java +++ b/src/main/java/build/buf/protovalidate/NumericTypeConfig.java @@ -271,7 +271,6 @@ private static int doubleCompare(Double d1, Double d2) { if (d1.isNaN() && d2.isNaN()) { return -1; } - return d1.compareTo(d2); } From 819a9cebe2761186b82741831fd8b5c835869240 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 18:37:40 -0400 Subject: [PATCH 30/31] update conformance workflow --- .github/workflows/conformance.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index 898cf55d..3e5fd58d 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -37,7 +37,5 @@ jobs: token: ${{ secrets.BUF_TOKEN }} - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - - name: Test conformance (CEL-only) - run: make conformance-cel - - name: Test conformance (default mode) + - name: Test conformance run: make conformance From 2c66edea7f9110f94b15269ed5698065704aa962 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 5 May 2026 21:37:23 -0400 Subject: [PATCH 31/31] fix header name regex --- .../protovalidate/StringRulesEvaluator.java | 2 +- .../StringRulesEvaluatorTest.java | 91 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java b/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java index 5088d097..094ffff3 100644 --- a/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java +++ b/src/main/java/build/buf/protovalidate/StringRulesEvaluator.java @@ -106,7 +106,7 @@ private static RuleSite site(int fieldNumber, String ruleId) { private static final Pattern ULID_REGEX = Pattern.compile("^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$"); private static final Pattern HEADER_NAME_REGEX = - Pattern.compile("^:?[0-9a-zA-Z!#$%&\\\\'*+\\-.\\^_|~`]+$"); + Pattern.compile("^:?[0-9a-zA-Z!#$%&'*+.\\-^_|~`]+$"); private static final Pattern HEADER_VALUE_REGEX = Pattern.compile("^[^\\x00-\\x08\\x0A-\\x1F\\x7F]*$"); private static final Pattern LOOSE_REGEX = Pattern.compile("^[^\\x00\\x0A\\x0D]+$"); diff --git a/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java index 7a835580..1f2b5392 100644 --- a/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java +++ b/src/test/java/build/buf/protovalidate/StringRulesEvaluatorTest.java @@ -22,8 +22,11 @@ import com.example.noimports.validationtest.ExampleStringEmail; import com.example.noimports.validationtest.ExampleStringHostAndPort; import com.example.noimports.validationtest.ExampleStringMinMaxLen; +import com.example.noimports.validationtest.HttpHeaderName; import com.google.protobuf.Descriptors.FieldDescriptor; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** Validator-level tests for {@link StringRulesEvaluator}. */ class StringRulesEvaluatorTest { @@ -130,4 +133,92 @@ void nativeAndCelProduceEqualViolationProto() throws ValidationException { assertThat(nativeV.validate(msg).getViolations().get(0).toProto()) .isEqualTo(celV.validate(msg).getViolations().get(0).toProto()); } + + @ParameterizedTest + @ValueSource( + strings = { + "Content-Type", + "Content-Length", + "Accept", + "User-Agent", + "X-Forwarded-For", + "WWW-Authenticate", + "If-None-Match", + "Cache-Control", + "Set-Cookie", + "ETag", + ":method", + ":path", + ":status", + ":authority", + ":scheme", + "!", + "#", + "$", + "%", + "&", + "'", + "*", + "+", + "-", + ".", + "^", + "_", + "`", + "|", + "~", + "a", + "0", + ":a", + "A1!#$%&'*+-.^_|~`", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "MiXeDcAsE" + }) + void headerNameRegexValidationGood(String headerName) throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal(headerName).build(); + Validator v = nativeValidator(); + assertThat(v.validate(msg).isSuccess()).isTrue(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "", + ":", + " ", + "Content-Type ", + " Content-Type", + "Content Type", + "\tContent-Type", + "Content:Type", + "Content/Type", + "Content\\Type", + "Content,Type", + "Content;Type", + "Content=Type", + "Content(Type)", + "Content[Type]", + "Content{Type}", + "Content", + "Content\"Type", + "Content?Type", + "Content@Type", + "::method", + "method:", + ":method:extra", + "Conténg-Type", + "内容类型", + "Header™", + "naïve", + "Content\000Type", + "Content\177Type", + "Content\nType", + "Content\rType", + "Valid-Name\nAnother-Name", + }) + void headerNameRegexValidationBad(String headerName) throws ValidationException { + HttpHeaderName msg = HttpHeaderName.newBuilder().setVal(headerName).build(); + Validator v = nativeValidator(); + assertThat(v.validate(msg).isSuccess()).isFalse(); + } }