From 9403d93d06e3c78fe7bdcbf51dbe9d63d7f5064b Mon Sep 17 00:00:00 2001 From: Stefan VanBuren Date: Mon, 25 Aug 2025 13:01:26 -0400 Subject: [PATCH] Fix instance check for concatenated values Fixes #353. --- gen/tests/example/v1/validations_pb2.py | 6 +++++- gen/tests/example/v1/validations_pb2.pyi | 8 ++++++++ proto/tests/example/v1/validations.proto | 13 +++++++++++++ protovalidate/internal/extra_func.py | 2 +- test/test_validate.py | 8 ++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/gen/tests/example/v1/validations_pb2.py b/gen/tests/example/v1/validations_pb2.py index dc397ffa..f877d20b 100644 --- a/gen/tests/example/v1/validations_pb2.py +++ b/gen/tests/example/v1/validations_pb2.py @@ -40,7 +40,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"tests/example/v1/validations.proto\x12\x10tests.example.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"T\n\x13MultipleValidations\x12 \n\x05title\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooR\x05title\x12\x1b\n\x04name\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x05R\x04name\")\n\x0c\x44oubleFinite\x12\x19\n\x03val\x18\x01 \x01(\x01\x42\x07\xbaH\x04\x12\x02@\x01R\x03val\";\n\x0eSFixed64ExLTGT\x12)\n\x03val\x18\x01 \x01(\x10\x42\x17\xbaH\x14\x62\x12\x11\x00\x00\x00\x00\x00\x00\x00\x00!\n\x00\x00\x00\x00\x00\x00\x00R\x03val\")\n\x0cTestOneofMsg\x12\x19\n\x03val\x18\x01 \x01(\x08\x42\x07\xbaH\x04j\x02\x08\x01R\x03val\"q\n\x05Oneof\x12\x1a\n\x01x\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooH\x00R\x01x\x12\x17\n\x01y\x18\x02 \x01(\x05\x42\x07\xbaH\x04\x1a\x02 \x00H\x00R\x01y\x12.\n\x01z\x18\x03 \x01(\x0b\x32\x1e.tests.example.v1.TestOneofMsgH\x00R\x01zB\x03\n\x01o\"[\n\x12ProtovalidateOneof\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x0b\xbaH\x08\"\x06\n\x01\x61\n\x01\x62\"e\n\x1aProtovalidateOneofRequired\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\r\xbaH\n\"\x08\n\x01\x61\n\x01\x62\x10\x01\"p\n\"ProtovalidateOneofUnknownFieldName\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x10\xbaH\r\"\x0b\n\x01\x61\n\x01\x62\n\x03xxx\"H\n\x0eTimestampGTNow\x12\x36\n\x03val\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x08\xbaH\x05\xb2\x01\x02@\x01R\x03val\"\x87\x01\n\tMapMinMax\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32$.tests.example.v1.MapMinMax.ValEntryB\n\xbaH\x07\x9a\x01\x04\x08\x02\x10\x04R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\x08R\x05value:\x02\x38\x01\"\x85\x01\n\x07MapKeys\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32\".tests.example.v1.MapKeys.ValEntryB\x0c\xbaH\t\x9a\x01\x06\"\x04\x42\x02\x10\x00R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\x12R\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\"\n\x05\x45mbed\x12\x19\n\x03val\x18\x01 \x01(\x03\x42\x07\xbaH\x04\"\x02 \x00R\x03val\"K\n\x11RepeatedEmbedSkip\x12\x36\n\x03val\x18\x01 \x03(\x0b\x32\x17.tests.example.v1.EmbedB\x0b\xbaH\x08\x92\x01\x05\"\x03\xd8\x01\x03R\x03val\"3\n\x0fInvalidRESyntax\x12 \n\x05value\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x32\x03^\\zR\x05valueB\x8a\x01\n\x14\x63om.tests.example.v1B\x10ValidationsProtoP\x01\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"tests/example/v1/validations.proto\x12\x10tests.example.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"T\n\x13MultipleValidations\x12 \n\x05title\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooR\x05title\x12\x1b\n\x04name\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x05R\x04name\")\n\x0c\x44oubleFinite\x12\x19\n\x03val\x18\x01 \x01(\x01\x42\x07\xbaH\x04\x12\x02@\x01R\x03val\";\n\x0eSFixed64ExLTGT\x12)\n\x03val\x18\x01 \x01(\x10\x42\x17\xbaH\x14\x62\x12\x11\x00\x00\x00\x00\x00\x00\x00\x00!\n\x00\x00\x00\x00\x00\x00\x00R\x03val\")\n\x0cTestOneofMsg\x12\x19\n\x03val\x18\x01 \x01(\x08\x42\x07\xbaH\x04j\x02\x08\x01R\x03val\"q\n\x05Oneof\x12\x1a\n\x01x\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooH\x00R\x01x\x12\x17\n\x01y\x18\x02 \x01(\x05\x42\x07\xbaH\x04\x1a\x02 \x00H\x00R\x01y\x12.\n\x01z\x18\x03 \x01(\x0b\x32\x1e.tests.example.v1.TestOneofMsgH\x00R\x01zB\x03\n\x01o\"[\n\x12ProtovalidateOneof\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x0b\xbaH\x08\"\x06\n\x01\x61\n\x01\x62\"e\n\x1aProtovalidateOneofRequired\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\r\xbaH\n\"\x08\n\x01\x61\n\x01\x62\x10\x01\"p\n\"ProtovalidateOneofUnknownFieldName\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x10\xbaH\r\"\x0b\n\x01\x61\n\x01\x62\n\x03xxx\"H\n\x0eTimestampGTNow\x12\x36\n\x03val\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x08\xbaH\x05\xb2\x01\x02@\x01R\x03val\"\x87\x01\n\tMapMinMax\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32$.tests.example.v1.MapMinMax.ValEntryB\n\xbaH\x07\x9a\x01\x04\x08\x02\x10\x04R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\x08R\x05value:\x02\x38\x01\"\x85\x01\n\x07MapKeys\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32\".tests.example.v1.MapKeys.ValEntryB\x0c\xbaH\t\x9a\x01\x06\"\x04\x42\x02\x10\x00R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\x12R\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\"\n\x05\x45mbed\x12\x19\n\x03val\x18\x01 \x01(\x03\x42\x07\xbaH\x04\"\x02 \x00R\x03val\"K\n\x11RepeatedEmbedSkip\x12\x36\n\x03val\x18\x01 \x03(\x0b\x32\x17.tests.example.v1.EmbedB\x0b\xbaH\x08\x92\x01\x05\"\x03\xd8\x01\x03R\x03val\"3\n\x0fInvalidRESyntax\x12 \n\x05value\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x32\x03^\\zR\x05value\"\xa9\x01\n\x12\x43oncatenatedValues\x12\x10\n\x03\x62\x61r\x18\x01 \x03(\tR\x03\x62\x61r\x12\x10\n\x03\x62\x61z\x18\x02 \x03(\tR\x03\x62\x61z:o\xbaHl\x1aj\n\x15globally_unique_names\x12\x31\x61ll values in bar and baz must be globally unique\x1a\x1e(this.bar + this.baz).unique()B\x8a\x01\n\x14\x63om.tests.example.v1B\x10ValidationsProtoP\x01\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -84,6 +84,8 @@ _globals['_REPEATEDEMBEDSKIP'].fields_by_name['val']._serialized_options = b'\272H\010\222\001\005\"\003\330\001\003' _globals['_INVALIDRESYNTAX'].fields_by_name['value']._loaded_options = None _globals['_INVALIDRESYNTAX'].fields_by_name['value']._serialized_options = b'\272H\007r\0052\003^\\z' + _globals['_CONCATENATEDVALUES']._loaded_options = None + _globals['_CONCATENATEDVALUES']._serialized_options = b'\272Hl\032j\n\025globally_unique_names\0221all values in bar and baz must be globally unique\032\036(this.bar + this.baz).unique()' _globals['_MULTIPLEVALIDATIONS']._serialized_start=118 _globals['_MULTIPLEVALIDATIONS']._serialized_end=202 _globals['_DOUBLEFINITE']._serialized_start=204 @@ -116,4 +118,6 @@ _globals['_REPEATEDEMBEDSKIP']._serialized_end=1235 _globals['_INVALIDRESYNTAX']._serialized_start=1237 _globals['_INVALIDRESYNTAX']._serialized_end=1288 + _globals['_CONCATENATEDVALUES']._serialized_start=1291 + _globals['_CONCATENATEDVALUES']._serialized_end=1460 # @@protoc_insertion_point(module_scope) diff --git a/gen/tests/example/v1/validations_pb2.pyi b/gen/tests/example/v1/validations_pb2.pyi index 88aec111..0b916ba5 100644 --- a/gen/tests/example/v1/validations_pb2.pyi +++ b/gen/tests/example/v1/validations_pb2.pyi @@ -137,3 +137,11 @@ class InvalidRESyntax(_message.Message): VALUE_FIELD_NUMBER: _ClassVar[int] value: str def __init__(self, value: _Optional[str] = ...) -> None: ... + +class ConcatenatedValues(_message.Message): + __slots__ = ("bar", "baz") + BAR_FIELD_NUMBER: _ClassVar[int] + BAZ_FIELD_NUMBER: _ClassVar[int] + bar: _containers.RepeatedScalarFieldContainer[str] + baz: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, bar: _Optional[_Iterable[str]] = ..., baz: _Optional[_Iterable[str]] = ...) -> None: ... diff --git a/proto/tests/example/v1/validations.proto b/proto/tests/example/v1/validations.proto index e9424510..e4c58697 100644 --- a/proto/tests/example/v1/validations.proto +++ b/proto/tests/example/v1/validations.proto @@ -111,3 +111,16 @@ message RepeatedEmbedSkip { message InvalidRESyntax { string value = 1 [(buf.validate.field).string.pattern = "^\\z"]; } + +// via #353. + +message ConcatenatedValues { + option (buf.validate.message).cel = { + id: "globally_unique_names" + message: "all values in bar and baz must be globally unique" + expression: "(this.bar + this.baz).unique()" + }; + + repeated string bar = 1; + repeated string baz = 2; +} diff --git a/protovalidate/internal/extra_func.py b/protovalidate/internal/extra_func.py index b5a726f7..5caaaa50 100644 --- a/protovalidate/internal/extra_func.py +++ b/protovalidate/internal/extra_func.py @@ -332,7 +332,7 @@ def cel_is_inf(val: celtypes.Value, sign: typing.Optional[celtypes.Value] = None def cel_unique(val: celtypes.Value) -> celpy.Result: - if not isinstance(val, celtypes.ListType): + if not isinstance(val, celtypes.ListType) and not isinstance(val, list): msg = "invalid argument, expected list" raise celpy.CELEvalError(msg) return celtypes.BoolType(len(val) == len(set(val))) diff --git a/test/test_validate.py b/test/test_validate.py index b7b26370..2233ca6c 100644 --- a/test/test_validate.py +++ b/test/test_validate.py @@ -179,6 +179,14 @@ def test_multiple_validations(self): self._run_invalid_tests(msg, [expected_violation1, expected_violation2]) + def test_concatenated_values(self): + msg = validations_pb2.ConcatenatedValues( + bar=["a", "b", "c"], + baz=["d", "e", "f"], + ) + + self._run_valid_tests(msg) + def test_fail_fast(self): """Test that fail fast correctly fails on first violation