From 455d9429259f1039520bdf749ef4618afa9e6167 Mon Sep 17 00:00:00 2001 From: Pastoray Date: Fri, 11 Sep 2026 02:54:22 +0100 Subject: [PATCH] feat(c++): add field validation options for cpp generator --- compiler/fory_compiler/frontend/fdl/parser.py | 3 +- compiler/fory_compiler/generators/cpp.py | 200 +++++++++++++++++- compiler/fory_compiler/ir/validator.py | 161 ++++++++++++++ .../tests/test_generated_code.py | 63 ++++++ .../tests/test_validation_options.py | 199 +++++++++++++++++ docs/compiler/schema-idl.md | 44 ++++ 6 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 compiler/fory_compiler/tests/test_validation_options.py diff --git a/compiler/fory_compiler/frontend/fdl/parser.py b/compiler/fory_compiler/frontend/fdl/parser.py index 31216ff77e..a3d867cc63 100644 --- a/compiler/fory_compiler/frontend/fdl/parser.py +++ b/compiler/fory_compiler/frontend/fdl/parser.py @@ -19,6 +19,7 @@ import warnings from typing import List, Set, Optional, Tuple +from fory_compiler.ir.validator import ValidationRule from fory_compiler.ir.ast import ( Schema, @@ -77,7 +78,7 @@ "thread_safe_pointer", "weak_ref", "java_array", -} +} | { rule.value for rule in ValidationRule } KNOWN_REF_OPTIONS: Set[str] = { "weak", diff --git a/compiler/fory_compiler/generators/cpp.py b/compiler/fory_compiler/generators/cpp.py index cc98b2d9dd..6af88d66ee 100644 --- a/compiler/fory_compiler/generators/cpp.py +++ b/compiler/fory_compiler/generators/cpp.py @@ -39,6 +39,7 @@ Schema, ) from fory_compiler.ir.types import PrimitiveKind +from fory_compiler.ir.validator import ValidationRule class CppGenerator(CppServiceGeneratorMixin, BaseGenerator): @@ -901,6 +902,200 @@ def generate_bytes_methods(self, class_name: str, indent: str) -> List[str]: lines.append(f"{indent}}}") return lines + def generate_validator(self) -> List[str]: + lines: List[str] = [] + lines.append(f"namespace Validator {{") + for message in self.schema.messages: + lines.extend(self.generate_message_validation(message, 0, [])) + for union in self.schema.unions: + lines.extend(self.generate_union_validation(union, 0, [])) + lines.append(f"}} // namespace Validator") + return lines + + def _validation_access(self, message: Message, field: Field, stack: List[Message]) -> Tuple[Optional[str], str]: + """Return (guard, value) for accessing a field's value inside the validator.""" + fname = self.get_field_identifier(message, field) + is_msg = self.is_message_type(field.field_type, stack) + weak_ref = self.get_field_weak_ref(field) + + if is_msg and weak_ref: + return (f"if (auto p = obj.{fname}().upgrade())", "*p") + if is_msg and field.ref: + return (f"if (obj.{fname}())", f"*obj.{fname}()") + if is_msg or field.optional: + return (f"if (obj.has_{fname}())", f"obj.{fname}()") + return (None, f"obj.{fname}()") + + + def generate_message_validation(self, message: Message, indent: int, stack: List[Message]) -> List[str]: + email_pattern = R"(^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$)" + lines: List[str] = [] + lines.append(self.indent(f"namespace {message.name} {{", indent)) + + stack.append(message) + for nested in message.nested_messages: + lines.extend(self.generate_message_validation(nested, indent + 1, stack)) + for nested in message.nested_unions: + lines.extend(self.generate_union_validation(nested, indent + 1, stack)) + stack.pop() + + lines.append(self.indent(f"bool validate(const ::{self.get_namespaced_type_name(message.name, stack)}& obj) {{", indent + 1)) + lines.append(self.indent(f"bool ok = true;", indent + 2)) + + for field in message.fields: + field_name = self.get_field_identifier(message, field) + guard, value = self._validation_access(message, field, stack + [message]) + resolved = self.resolve_named_type(field.field_type.name, stack + [message]) if isinstance(field.field_type, NamedType) else None + if isinstance(resolved, Message) or isinstance(resolved, Union): + nested_id = self.get_type_identifier(resolved) + if guard: + lines.append(self.indent(f"{guard} {{ ok &= {nested_id}::validate({value}); }}", indent + 2)) + else: + lines.append(self.indent(f"ok &= {nested_id}::validate({value});", indent + 2)) + option_lines: List[str] = [] + for key, rule_val in field.options.items(): + try: + rule = ValidationRule(key) + except ValueError: + continue + if rule == ValidationRule.GTE: + option_lines.append(self.indent(f"ok &= ({value} >= {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.LTE: + option_lines.append(self.indent(f"ok &= ({value} <= {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.GT: + option_lines.append(self.indent(f"ok &= ({value} > {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.LT: + option_lines.append(self.indent(f"ok &= ({value} < {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.EQL: + option_lines.append(self.indent(f"ok &= ({value} == {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.NEQ: + option_lines.append(self.indent(f"ok &= ({value} != {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.MIN_LEN: + option_lines.append(self.indent(f"ok &= ({value}.size() >= {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.MAX_LEN: + option_lines.append(self.indent(f"ok &= ({value}.size() <= {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.PATTERN: + option_lines.append(self.indent(f"static const std::regex re_{field_name}(R\"({rule_val})\");", indent + 3 if guard else indent + 2)) + option_lines.append(self.indent(f"ok &= (std::regex_match({value}, re_{field_name}));", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.EMAIL and rule_val: + option_lines.append(self.indent(f"static const std::regex re_{field_name}(R\"({email_pattern})\");", indent + 3 if guard else indent + 2)) + option_lines.append(self.indent(f"ok &= (std::regex_match({value}, re_{field_name}));", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.UUID and rule_val: + i3 = indent + 3 if guard else indent + 2 + option_lines.append(self.indent(f"ok &= ({value}.size() == 36);", i3)) + option_lines.append(self.indent(f"if ({value}.size() == 36) {{", i3)) + option_lines.append(self.indent(f"for (size_t i = 0; i < 36; i++) {{", i3 + 1)) + option_lines.append(self.indent(f"if (i == 8 || i == 13 || i == 18 || i == 23) {{", i3 + 2)) + option_lines.append(self.indent(f"ok &= ({value}[i] == '-');", i3 + 3)) + option_lines.append(self.indent(f"}} else {{", i3 + 2)) + option_lines.append(self.indent( + f"ok &= (std::isxdigit(static_cast({value}[i])));", + i3 + 3 + )) + option_lines.append(self.indent(f"}}", i3 + 2)) + option_lines.append(self.indent(f"}}", i3 + 1)) + option_lines.append(self.indent(f"}}", i3)) + elif rule == ValidationRule.MIN_ITEMS: + option_lines.append(self.indent(f"ok &= ({value}.size() >= {rule_val});", indent + 3 if guard else indent + 2)) + elif rule == ValidationRule.MAX_ITEMS: + option_lines.append(self.indent(f"ok &= ({value}.size() <= {rule_val});", indent + 3 if guard else indent + 2)) + + if option_lines: + if guard: + lines.append(self.indent(f"{guard} {{", indent + 2)) + lines.extend(option_lines) + lines.append(self.indent(f"}}", indent + 2)) + else: + lines.extend(option_lines) + + lines.append(self.indent(f"return ok;", indent + 2)) + lines.append(self.indent(f"}}", indent + 1)) + lines.append(self.indent(f"}} // namespace {message.name}", indent)) + return lines + + def generate_union_validation(self, union: Union, indent: int, stack: List[Message]) -> List[str]: + email_pattern = R"(^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$)" + lines: List[str] = [] + lines.append(self.indent(f"namespace {union.name} {{", indent)) + lines.append(self.indent( + f"bool validate(const ::{self.get_namespaced_type_name(union.name, stack)}& obj) {{", + indent + 1 + )) + lines.append(self.indent(f"bool ok = true;", indent + 2)) + + for field in union.fields: + field_name = self.get_union_case_identifier(union, field) + value = f"obj.{field_name}()" + guard = f"if (obj.is_{field_name}())" + + case_lines: List[str] = [] + resolved = self.resolve_named_type(field.field_type.name, stack) if isinstance(field.field_type, NamedType) else None + if isinstance(resolved, Message): + nested_id = self.get_type_identifier(resolved) + case_lines.append(self.indent(f"ok &= {nested_id}::validate({value});", indent + 3)) + + for key, rule_val in field.options.items(): + try: + rule = ValidationRule(key) + except ValueError: + continue + if rule == ValidationRule.GTE: + case_lines.append(self.indent(f"ok &= ({value} >= {rule_val});", indent + 3)) + elif rule == ValidationRule.LTE: + case_lines.append(self.indent(f"ok &= ({value} <= {rule_val});", indent + 3)) + elif rule == ValidationRule.GT: + case_lines.append(self.indent(f"ok &= ({value} > {rule_val});", indent + 3)) + elif rule == ValidationRule.LT: + case_lines.append(self.indent(f"ok &= ({value} < {rule_val});", indent + 3)) + elif rule == ValidationRule.EQL: + case_lines.append(self.indent(f"ok &= ({value} == {rule_val});", indent + 3)) + elif rule == ValidationRule.NEQ: + case_lines.append(self.indent(f"ok &= ({value} != {rule_val});", indent + 3)) + elif rule == ValidationRule.MIN_LEN: + case_lines.append(self.indent(f"ok &= ({value}.size() >= {rule_val});", indent + 3)) + elif rule == ValidationRule.MAX_LEN: + case_lines.append(self.indent(f"ok &= ({value}.size() <= {rule_val});", indent + 3)) + elif rule == ValidationRule.PATTERN: + case_lines.append(self.indent(f"static const std::regex re_{field_name}(R\"({rule_val})\");", indent + 3)) + case_lines.append(self.indent( + f"ok &= (std::regex_match({value}, re_{field_name}));", + indent + 3 + )) + elif rule == ValidationRule.EMAIL and rule_val: + case_lines.append(self.indent(f"static const std::regex re_{field_name}(R\"({email_pattern})\");", indent + 3)) + case_lines.append(self.indent( + f"ok &= (std::regex_match({value}, re_{field_name}));", + indent + 3 + )) + elif rule == ValidationRule.UUID and rule_val: + case_lines.append(self.indent(f"ok &= ({value}.size() == 36);", indent + 3)) + case_lines.append(self.indent(f"if ({value}.size() == 36) {{", indent + 3)) + case_lines.append(self.indent(f"for (size_t i = 0; i < 36; i++) {{", indent + 4)) + case_lines.append(self.indent(f"if (i == 8 || i == 13 || i == 18 || i == 23) {{", indent + 5)) + case_lines.append(self.indent(f"ok &= ({value}[i] == '-');", indent + 6)) + case_lines.append(self.indent(f"}} else {{", indent + 5)) + case_lines.append(self.indent( + f"ok &= (std::isxdigit(static_cast({value}[i])));", + indent + 6 + )) + case_lines.append(self.indent(f"}}", indent + 5)) + case_lines.append(self.indent(f"}}", indent + 4)) + case_lines.append(self.indent(f"}}", indent + 3)) + elif rule == ValidationRule.MIN_ITEMS: + case_lines.append(self.indent(f"ok &= ({value}.size() >= {rule_val});", indent + 3)) + elif rule == ValidationRule.MAX_ITEMS: + case_lines.append(self.indent(f"ok &= ({value}.size() <= {rule_val});", indent + 3)) + + if case_lines: + lines.append(self.indent(f"{guard} {{", indent + 2)) + lines.extend(case_lines) + lines.append(self.indent(f"}}", indent + 2)) + + lines.append(self.indent(f"return ok;", indent + 2)) + lines.append(self.indent(f"}}", indent + 1)) + lines.append(self.indent(f"}} // namespace {union.name}", indent)) + return lines + def generate_header(self) -> GeneratedFile: """Generate a C++ header file with all types.""" lines = [] @@ -918,9 +1113,11 @@ def generate_header(self) -> GeneratedFile: includes.add("") includes.add("") includes.add("") + includes.add("") + includes.add("") + includes.add("") includes.add('"fory/serialization/fory.h"') if self.schema_has_unions(): - includes.add("") includes.add("") includes.add("") includes.add("") @@ -1013,6 +1210,7 @@ def generate_header(self) -> GeneratedFile: lines.extend(self.generate_registration()) lines.append("") + lines.extend(self.generate_validator()) if namespace: lines.append(f"}} // namespace {namespace}") lines.append("") diff --git a/compiler/fory_compiler/ir/validator.py b/compiler/fory_compiler/ir/validator.py index da8d84106d..21bc505a45 100644 --- a/compiler/fory_compiler/ir/validator.py +++ b/compiler/fory_compiler/ir/validator.py @@ -19,6 +19,8 @@ from dataclasses import dataclass from typing import List, Optional, Union as TypingUnion +from enum import Enum as PyEnum +import re from fory_compiler.ir.ast import ( Schema, @@ -50,6 +52,20 @@ OPTIONAL_ANY_MESSAGE = "optional or nullable any is not supported; use any instead" MAX_FIELD_TAG_ID = (1 << 29) - 1 +class ValidationRule(PyEnum): + GTE = "gte" + LTE = "lte" + GT = "gt" + LT = "lt" + EQL = "eql" + NEQ = "neq" + MIN_LEN = "min_len" + MAX_LEN = "max_len" + PATTERN = "pattern" + UUID = "uuid" + EMAIL = "email" + MIN_ITEMS = "min_items" + MAX_ITEMS = "max_items" @dataclass class ValidationIssue: @@ -67,6 +83,20 @@ def __str__(self) -> str: class SchemaValidator: """Validates a Fory IR schema.""" + NUMERIC_PRIMITIVES = { + PrimitiveKind.INT8, + PrimitiveKind.INT16, + PrimitiveKind.INT32, + PrimitiveKind.INT64, + PrimitiveKind.UINT8, + PrimitiveKind.UINT16, + PrimitiveKind.UINT32, + PrimitiveKind.UINT64, + PrimitiveKind.FLOAT16, + PrimitiveKind.BFLOAT16, + PrimitiveKind.FLOAT32, + PrimitiveKind.FLOAT64, + } def __init__(self, schema: Schema, allow_nested_collections: bool = False): self.schema = schema @@ -88,6 +118,7 @@ def validate(self) -> bool: self._check_collection_nesting() self._check_ref_rules() self._check_weak_refs() + self._check_validation_options() return not self.errors def _error(self, message: str, location: Optional[SourceLocation]) -> None: @@ -309,6 +340,136 @@ def validate_union(union: Union, parent_path: str = ""): for message in self.schema.messages: validate_message(message) + def _check_validation_options(self) -> None: + def walk_nested_messages(message: Message, parent_name: str) -> None: + for nested in message.nested_messages: + full = f"{parent_name}.{nested.name}" + for field in nested.fields: + self._check_field_options(field, full) + walk_nested_messages(nested, full) + + for nested in message.nested_unions: + full = f"{parent_name}.{nested.name}" + for field in nested.fields: + self._check_field_options(field, full) + + for message in self.schema.messages: + for field in message.fields: + self._check_field_options(field, message.name) + walk_nested_messages(message, message.name) + + for union in self.schema.unions: + for field in union.fields: + self._check_field_options(field, union.name) + + def _check_field_options(self, field: Field, full_name: str) -> None: + """Validate that the option is appropriate for the field's type.""" + ft = field.field_type + re_rules_present = [] + for key in field.options: + try: + r = ValidationRule(key) + except ValueError: + continue + if r in (ValidationRule.PATTERN, ValidationRule.EMAIL, ValidationRule.UUID): + re_rules_present.append(r.value) + + if len(re_rules_present) > 1: + self._error( + f"Field '{full_name}.{field.name}' cannot combine " + f"{', '.join(re_rules_present)}. Pick one of pattern, email, uuid", + field.location + ) + + for key, val in field.options.items(): + try: + rule = ValidationRule(key) + except ValueError: + continue + + if rule in ( + ValidationRule.GTE, ValidationRule.LTE, ValidationRule.GT, + ValidationRule.LT, ValidationRule.EQL, ValidationRule.NEQ + ): + if not isinstance(ft, PrimitiveType) or ft.kind not in self.NUMERIC_PRIMITIVES: + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a numeric primitive type, " + f"but got {type(ft).__name__}", + field.location + ) + + elif rule in ( + ValidationRule.MIN_LEN, ValidationRule.MAX_LEN, + ValidationRule.PATTERN, ValidationRule.UUID, ValidationRule.EMAIL + ): + if not isinstance(ft, PrimitiveType) or ft.kind != PrimitiveKind.STRING: + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a string type, " + f"but got {type(ft).__name__}", + field.location + ) + + elif rule in (ValidationRule.MIN_ITEMS, ValidationRule.MAX_ITEMS): + if not isinstance(ft, (ListType, ArrayType, MapType)): + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a collection type (list/array/map), " + f"but got {type(ft).__name__}", + field.location + ) + + if rule in ( + ValidationRule.GTE, ValidationRule.LTE, ValidationRule.GT, + ValidationRule.LT, ValidationRule.EQL, ValidationRule.NEQ, + ): + if not isinstance(val, (int, float)) or isinstance(val, bool): + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a numeric primitive value, " + f"but got {val}", + field.location + ) + + elif rule in ( + ValidationRule.MIN_LEN, ValidationRule.MAX_LEN, + ValidationRule.MIN_ITEMS, ValidationRule.MAX_ITEMS + ): + if not isinstance(val, int) or isinstance(val, bool): + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a numeric primitive value, " + f"but got {val}", + field.location + ) + + elif val < 0: + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a positive numeric primitive value, " + f"but got {val}", + field.location + ) + + elif rule in (ValidationRule.EMAIL, ValidationRule.UUID): + if not isinstance(val, bool): + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a boolean value, " + f"but got {val}", + field.location + ) + + elif rule == ValidationRule.PATTERN: + if not isinstance(val, str): + self._error( + f"Field '{full_name}.{field.name}' option '{key}' requires a string value, " + f"but got {val}", + field.location + ) + else: + try: + re.compile(val) + except re.error as e: + self._error( + f"Field '{full_name}.{field.name}' option 'pattern' has invalid regex: {e}", + field.location + ) + def _apply_field_defaults(self) -> None: def apply_message_fields( message: Message, diff --git a/compiler/fory_compiler/tests/test_generated_code.py b/compiler/fory_compiler/tests/test_generated_code.py index ee6654df22..569d5fb225 100644 --- a/compiler/fory_compiler/tests/test_generated_code.py +++ b/compiler/fory_compiler/tests/test_generated_code.py @@ -1576,6 +1576,69 @@ def test_cpp_temporal_map_keys_use_fory_owned_wrappers(): ) assert "std::map<" not in cpp_output +def test_cpp_validation_options(): + fdl = dedent( + """ + package test; + + message Address { + string street = 1 [min_len = 1, max_len = 100]; + int32 zipcode = 2 [gte = 10000, lte = 99999]; + } + + message Account { + int64 account_id = 1 [gte = 1000, lte = 2000]; + string username = 2 [min_len = 4, max_len = 16, pattern = "^[a-zA-Z0-9]+$"]; + string email = 3 [email = true]; + string uuid = 4 [uuid = true]; + list tags = 5 [min_items = 1, max_items = 5]; + int32 status = 6 [eql = 1, neq = 3]; + Address address = 7; + } + + union Shape { + string name = 1 [max_len = 32]; + int32 sides = 2 [gte = 3, lte = 100]; + } + """ + ) + schema = parse_fdl(fdl) + cpp_output = render_files(generate_files(schema, CppGenerator)) + + assert "namespace Validator {" in cpp_output + assert "namespace Account {" in cpp_output + assert "namespace Address {" in cpp_output + assert "namespace Shape {" in cpp_output + + assert "bool validate(const ::test::Account& obj)" in cpp_output + assert "bool validate(const ::test::Address& obj)" in cpp_output + assert "bool validate(const ::test::Shape& obj)" in cpp_output + + assert "ok &= (obj.account_id() >= 1000);" in cpp_output + assert "ok &= (obj.account_id() <= 2000);" in cpp_output + assert "ok &= (obj.status() == 1);" in cpp_output + assert "ok &= (obj.status() != 3);" in cpp_output + + assert "ok &= (obj.username().size() >= 4);" in cpp_output + assert "ok &= (obj.username().size() <= 16);" in cpp_output + + assert 'static const std::regex re_username(R"(^[a-zA-Z0-9]+$)");' in cpp_output + assert 'std::regex_match(obj.username(), re_username)' in cpp_output + assert 'static const std::regex re_email(R"((^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$))");' in cpp_output + assert 'std::regex_match(obj.email(), re_email)' in cpp_output + + assert "ok &= (obj.uuid().size() == 36);" in cpp_output + assert "for (size_t i = 0; i < 36; i++)" in cpp_output + + assert "ok &= (obj.tags().size() >= 1);" in cpp_output + assert "ok &= (obj.tags().size() <= 5);" in cpp_output + + assert "if (obj.has_address()) { ok &= Address::validate(obj.address()); }" in cpp_output + assert "if (obj.is_name()) {" in cpp_output + assert "ok &= (obj.name().size() <= 32);" in cpp_output + assert "if (obj.is_sides()) {" in cpp_output + assert "ok &= (obj.sides() >= 3);" in cpp_output + assert "ok &= (obj.sides() <= 100);" in cpp_output def test_java_enum_generation_uses_fory_enum_ids(): schema = parse_fdl( diff --git a/compiler/fory_compiler/tests/test_validation_options.py b/compiler/fory_compiler/tests/test_validation_options.py new file mode 100644 index 0000000000..bad55ad860 --- /dev/null +++ b/compiler/fory_compiler/tests/test_validation_options.py @@ -0,0 +1,199 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +"""Tests for validation rule options in the FDL schema validator.""" + +from textwrap import dedent +from typing import List + +from fory_compiler.frontend.fdl.lexer import Lexer +from fory_compiler.frontend.fdl.parser import Parser +from fory_compiler.ir.validator import SchemaValidator, ValidationIssue + +def _parse(source: str): + lexer = Lexer(dedent(source)) + parser = Parser(lexer.tokenize()) + return parser.parse() + +def _validate(source: str) -> List[ValidationIssue]: + schema = _parse(source) + validator = SchemaValidator(schema) + validator.validate() + return validator.errors + +def _assert_rejected(source: str, fragment: str) -> None: + errors = _validate(source) + assert errors, f"expected validation errors for:\n{source}" + messages = [e.message for e in errors] + assert any(fragment in m for m in messages), ( + f"expected {fragment!r} in errors: {messages}" + ) + + +def test_gte_on_non_numeric_field_rejected(): + _assert_rejected( + """ + package test; + message M { string s = 1 [gte = 1]; } + """, + "requires a numeric primitive type", + ) + + +def test_min_len_on_non_string_field_rejected(): + _assert_rejected( + """ + package test; + message M { int32 x = 1 [min_len = 1]; } + """, + "requires a string type", + ) + + +def test_email_on_non_string_field_rejected(): + _assert_rejected( + """ + package test; + message M { int32 x = 1 [email = true]; } + """, + "requires a string type", + ) + + +def test_min_items_on_non_collection_field_rejected(): + _assert_rejected( + """ + package test; + message M { int32 x = 1 [min_items = 1]; } + """, + "requires a collection type", + ) + + +def test_gte_with_bool_value_rejected(): + _assert_rejected( + """ + package test; + message M { int32 x = 1 [gte = true]; } + """, + "requires a numeric primitive value", + ) + + +def test_min_len_with_string_value_rejected(): + _assert_rejected( + """ + package test; + message M { string s = 1 [min_len = "four"]; } + """, + "requires a numeric primitive value", + ) + + +def test_min_len_with_negative_value_rejected(): + _assert_rejected( + """ + package test; + message M { string s = 1 [min_len = -1]; } + """, + "requires a positive numeric primitive value", + ) + + +def test_max_items_with_negative_value_rejected(): + _assert_rejected( + """ + package test; + message M { list xs = 1 [max_items = -1]; } + """, + "requires a positive numeric primitive value", + ) + + +def test_email_with_non_bool_value_rejected(): + _assert_rejected( + """ + package test; + message M { string s = 1 [email = "yes"]; } + """, + "requires a boolean value", + ) + + +def test_uuid_with_non_bool_value_rejected(): + _assert_rejected( + """ + package test; + message M { string s = 1 [uuid = 1]; } + """, + "requires a boolean value", + ) + + +def test_pattern_with_non_string_value_rejected(): + _assert_rejected( + """ + package test; + message M { string s = 1 [pattern = 42]; } + """, + "requires a string value", + ) + + +def test_validation_options_checked_in_nested_message(): + _assert_rejected( + """ + package test; + message Outer { + message Inner { + string s = 1 [min_len = "x"]; + } + Inner inner = 1; + } + """, + "requires a numeric primitive value", + ) + + +def test_validation_options_checked_in_union(): + _assert_rejected( + """ + package test; + union U { + int32 x = 1 [gte = true]; + } + """, + "requires a numeric primitive value", + ) + + +def test_valid_validation_options_accepted(): + errors = _validate( + """ + package test; + message M { + int32 x = 1 [gte = 0, lte = 100]; + string s = 2 [min_len = 1, max_len = 10]; + string e = 3 [email = true]; + string u = 4 [uuid = true]; + string p = 5 [pattern = "^[a-z]+$"]; + list xs = 6 [min_items = 1, max_items = 5]; + } + """ + ) + assert errors == [], f"unexpected errors: {[e.message for e in errors]}" + diff --git a/docs/compiler/schema-idl.md b/docs/compiler/schema-idl.md index 9c1244fdd9..cd07a18286 100644 --- a/docs/compiler/schema-idl.md +++ b/docs/compiler/schema-idl.md @@ -1152,6 +1152,50 @@ Use `ref(thread_safe=false)` in Fory IDL (or `[(fory).thread_safe_pointer = false]` in protobuf) to generate `Rc` instead of `Arc` in Rust. +### Validation Options + +Fields can declare validation rules as field options. The compiler checks +the option's type compatibility at schema compile time and generates +validator functions in supported target languages. + +| Option | Applies to | Meaning | +| ----------- | -------------------------------- | ------------------------------------- | +| `gte` | numeric primitives | value must be `>=` the given number | +| `lte` | numeric primitives | value must be `<=` the given number | +| `gt` | numeric primitives | value must be `>` the given number | +| `lt` | numeric primitives | value must be `<` the given number | +| `eql` | numeric primitives | value must equal the given number | +| `neq` | numeric primitives | value must not equal the given number | +| `min_len` | `string` | length must be `>=` the given count | +| `max_len` | `string` | length must be `<=` the given count | +| `pattern` | `string` | value must match the given regex | +| `uuid` | `string` | value must be a 36-char UUID | +| `email` | `string` | value must be a valid email address | +| `min_items` | `list` / `array` / `map` | collection size must be `>=` N | +| `max_items` | `list` / `array` / `map` | collection size must be `<=` N | + +**Rules:** + +- Numeric options accept integer or float values. +- Length and item-count options accept non-negative integers. +- `pattern` must be a valid regular expression. +- `email` and `uuid` are boolean flags: `[email = true]`, `[uuid = true]`. +- `pattern`, `email`, and `uuid` are mutually exclusive on the same field. +- Validation options may only be used on fields of a compatible type + (for example, `gte` on a non-numeric field is a compile error). + +**Example:** + +```protobuf +message Account { + int64 account_id = 1 [gte = 1000, lte = 2000000000]; + string username = 2 [min_len = 4, max_len = 16, pattern = "^[a-zA-Z0-9_]+$"]; + string email = 3 [email = true]; + string uuid = 4 [uuid = true]; + list tags = 5 [min_items = 1, max_items = 5]; +} +``` + ## Field Numbers Each field must have a unique tag ID in the protocol range: