diff --git a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json index f3e8c2abd..6a55d4207 100644 --- a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json @@ -350,6 +350,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -364,6 +371,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -378,6 +392,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -392,6 +413,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -406,6 +434,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -420,6 +455,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] diff --git a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json index a2ee027b5..a34ee42d4 100644 --- a/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_baseline_schema.json @@ -401,6 +401,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -415,6 +422,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -429,6 +443,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -443,6 +464,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -457,6 +485,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -471,6 +506,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -487,6 +529,13 @@ } }, "then": { + "properties": { + "parent_division_id": { + "not": { + "type": "null" + } + } + }, "required": [ "parent_division_id" ] @@ -502,6 +551,13 @@ }, "then": { "not": { + "properties": { + "parent_division_id": { + "not": { + "type": "null" + } + } + }, "required": [ "parent_division_id" ] diff --git a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json index 336d2d484..1d310d228 100644 --- a/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_boundary_baseline_schema.json @@ -231,6 +231,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -245,6 +252,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -259,6 +273,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -273,6 +294,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -287,6 +315,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -301,6 +336,13 @@ } }, "then": { + "properties": { + "admin_level": { + "not": { + "type": "null" + } + } + }, "required": [ "admin_level" ] @@ -317,6 +359,13 @@ } }, "then": { + "properties": { + "country": { + "not": { + "type": "null" + } + } + }, "required": [ "country" ] @@ -332,6 +381,13 @@ }, "then": { "not": { + "properties": { + "country": { + "not": { + "type": "null" + } + } + }, "required": [ "country" ] diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index c2ade1b8e..1e4b8be72 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -122,15 +122,19 @@ MyModel(foo=42, bar=None) >>> MyModel(bar="hello") # validates OK MyModel(foo=None, bar='hello') ->>> MyModel(foo=None, bar=None) # validates OK because foo and bar are explicitly set to `None` -MyModel(foo=None, bar=None) >>> >>> try: ... MyModel() ... except ValidationError as e: -... assert "at least one of these fields must be explicitly set, but none are: foo, bar" in str(e) -... print("Validation failed") -Validation failed +... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" in str(e) +... print("Validation failed (no fields set)") +Validation failed (no fields set) +>>> try: +... MyModel(foo=None, bar=None) +... except ValidationError as e: +... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" in str(e) +... print("Validation failed (all fields None)") +Validation failed (all fields None) Describe a foreign key relationship between two models where one model has a field that contains the unique identifier of another model. diff --git a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py index c9afce006..64bc288ac 100644 --- a/packages/overture-schema-system/src/overture/schema/system/_json_schema.py +++ b/packages/overture-schema-system/src/overture/schema/system/_json_schema.py @@ -331,6 +331,30 @@ def try_move(key: str, src: JsonSchemaValue, dst: JsonSchemaValue) -> None: pass +def required_non_null(aliases: list[str]) -> JsonSchemaValue: + """ + Build a JSON Schema requiring listed properties to be present and non-null. + + Combines `"required"` (property must exist) with a per-property + constraint `{"not": {"type": "null"}}` (value must not be null). + + Parameters + ---------- + aliases : list[str] + Non-empty list of JSON Schema property names to constrain + + Returns + ------- + JsonSchemaValue + Schema requiring each property to be present and non-null + """ + _verify_operands_not_empty(str, aliases) + return { + "required": aliases, + "properties": {a: {"not": {"type": "null"}} for a in aliases}, + } + + T = TypeVar("T", JsonSchemaValue, str) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py index ab0a8a329..c301edbd4 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/forbid_if.py @@ -1,5 +1,5 @@ """ -Prohibit every field in a group of fields from having a value explicitly set, but only if a +Prohibit every field in a group of fields from having a non-`None` value, but only if a condition is true. """ @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict from typing_extensions import override -from .._json_schema import get_static_json_schema_extra, put_if +from .._json_schema import get_static_json_schema_extra, put_if, required_non_null from .model_constraint import ( Condition, OptionalFieldGroupConstraint, @@ -22,12 +22,11 @@ def forbid_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint forbidding any of the named fields from - holding an explicitly-assigned value, but only if a field value condition is true. + holding a non-`None` value, but only if a field value condition is true. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to violate the constraint. This means in particular that fields whose value was set by - Pydantic using a default value do not count as having a set value, and fields containing the - value `None`, if this value was explicitly set rather than being inherited by default, do count. + set to a non-`None` value to violate the constraint. Fields containing the value `None`, + whether set explicitly or by default, are always compliant. Parameters ---------- @@ -56,11 +55,13 @@ def forbid_if( MyModel(foo='something', bar=42, baz='qux') >>> MyModel(foo='special value') # validates OK because bar/baz are omitted MyModel(foo='special value', bar=None, baz=None) + >>> MyModel(foo='special value', bar=None) # validates OK because None is compliant + MyModel(foo='special value', bar=None, baz=None) >>> >>> try: ... MyModel(foo='special value', bar=42) ... except ValidationError as e: - ... assert 'at least one field has an explicit value when it should not: bar' in str(e) + ... assert 'at least one field is set to a value other than None when it must not be: bar' in str(e) ... print('Validation failed') Validation failed """ @@ -123,13 +124,15 @@ def validate_instance(self, model_instance: BaseModel) -> None: return present_fields = [ - f for f in self.field_names if f in model_instance.model_fields_set + f + for f in self.field_names + if self._field_has_non_none_value(model_instance, f) ] if present_fields: raise ValueError( - f"at least one field has an explicit value when it should not: {', '.join(present_fields)} - " - f"these field value(s) are forbidden because {self.__condition} is true " + f"at least one field is set to a value other than None when it must not be: {', '.join(present_fields)} - " + f"these field(s) are forbidden because {self.__condition} is true " f"(`{self.name}`)" ) @@ -139,12 +142,9 @@ def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: json_schema = get_static_json_schema_extra(config) + aliases = [apply_alias(model_class, f) for f in self.field_names] put_if( json_schema, self.__condition.json_schema(model_class), - { - "not": { - "required": [apply_alias(model_class, f) for f in self.field_names] - } - }, + {"not": required_non_null(aliases)}, ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py index 9b8e0c924..ece264872 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/model_constraint.py @@ -334,6 +334,13 @@ class OptionalFieldGroupConstraint(FieldGroupConstraint): def __init__(self, name: str | None, field_names: tuple[str, ...]): super().__init__(name, field_names) + @staticmethod + def _field_has_non_none_value(model_instance: BaseModel, field_name: str) -> bool: + return ( + field_name in model_instance.model_fields_set + and getattr(model_instance, field_name) is not None + ) + @override def validate_class(self, model_class: type[BaseModel]) -> None: super().validate_class(model_class) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py index e131c0a66..f05e86043 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_any_of.py @@ -1,28 +1,26 @@ """ -Require at least one named field to have a value explicitly set. +Require at least one named field to have a non-`None` value. """ from collections.abc import Callable from pydantic import BaseModel, ConfigDict -from pydantic.json_schema import JsonDict from typing_extensions import override -from .._json_schema import get_static_json_schema_extra, put_any_of +from .._json_schema import get_static_json_schema_extra, put_any_of, required_non_null from .model_constraint import OptionalFieldGroupConstraint, apply_alias def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint requiring that at least one of the named - fields has a value explicitly set. + fields has a non-`None` value. This function is the decorator version of the `RequireAnyOfConstraint` class. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to satisfy the constraint. This means in particular that fields whose value was set by - Pydantic using a default value do not count as having a set value, and fields containing the - value `None`, if this value was explicitly set rather than being inherited by default, do count. + set to a non-`None` value to satisfy the constraint. Fields whose value was set by Pydantic + using a default value violate the constraint, as do fields explicitly set to `None`. Parameters ---------- @@ -49,16 +47,21 @@ def require_any_of(*field_names: str) -> Callable[[type[BaseModel]], type[BaseMo MyModel(foo=42, bar=None) >>> MyModel(bar="hello") # validates OK MyModel(foo=None, bar='hello') - >>> MyModel(foo=None, bar=None) # validates OK - MyModel(foo=None, bar=None) >>> >>> try: ... MyModel() ... except ValidationError as e: - ... assert "at least one of these fields must be explicitly set, but none are: foo, bar" \ + ... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" \ in str(e) - ... print("Validation failed") - Validation failed + ... print("Validation failed (no fields set)") + Validation failed (no fields set) + >>> try: + ... MyModel(foo=None, bar=None) + ... except ValidationError as e: + ... assert "at least one of these fields must be set to a value other than None, but none are: foo, bar" \ + in str(e) + ... print("Validation failed (all fields None)") + Validation failed (all fields None) """ model_constraint = RequireAnyOfConstraint._create_internal( f"@{require_any_of.__name__}", *field_names @@ -97,11 +100,11 @@ def __validate_field_names(field_names: tuple[str, ...]) -> tuple[str, ...]: def validate_instance(self, model_instance: BaseModel) -> None: super().validate_instance(model_instance) - if not ( - any(f for f in self.field_names if f in model_instance.model_fields_set) + if not any( + self._field_has_non_none_value(model_instance, f) for f in self.field_names ): raise ValueError( - f"at least one of these fields must be explicitly set, but none are: {', '.join(self.field_names)} (`{self.name}`)" + f"at least one of these fields must be set to a value other than None, but none are: {', '.join(self.field_names)} (`{self.name}`)" ) @override @@ -110,7 +113,10 @@ def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: json_schema = get_static_json_schema_extra(config) - def required(field_name: str) -> JsonDict: - return {"required": [apply_alias(model_class, field_name)]} - - put_any_of(json_schema, [required(f) for f in self.field_names]) + put_any_of( + json_schema, + [ + required_non_null([apply_alias(model_class, f)]) + for f in self.field_names + ], + ) diff --git a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py index 4cb5b138e..fbc354d2f 100644 --- a/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py +++ b/packages/overture-schema-system/src/overture/schema/system/model_constraint/require_if.py @@ -1,5 +1,5 @@ """ -Require every field in a group of fields to have a value explicitly set, but only if a condition is +Require every field in a group of fields to have a non-`None` value, but only if a condition is true. """ @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict from typing_extensions import override -from .._json_schema import get_static_json_schema_extra, put_if +from .._json_schema import get_static_json_schema_extra, put_if, required_non_null from .model_constraint import ( Condition, OptionalFieldGroupConstraint, @@ -22,12 +22,12 @@ def require_if( ) -> Callable[[type[BaseModel]], type[BaseModel]]: """ Decorate a Pydantic model class with a constraint requiring all of the named fields to have a - value explicitly set, but only if a condition is true. + non-`None` value, but only if a condition is true. To ensure parity between Python and JSON Schema validation, a field's value must be explicitly - set to satisfy the constraint. This means in particular that fields whose value was set by - Pydantic using a default value do not count as having a set value, and fields containing the - value `None`, if this value was explicitly set rather than being inherited by default, do count. + set to a non-`None` value to satisfy the constraint. Fields whose value was set by Pydantic + using a default are treated as absent and violate the constraint, as do fields explicitly set + to `None`. Parameters ---------- @@ -61,7 +61,16 @@ def require_if( ... MyModel(foo='special value') ... except ValidationError as e: ... assert ( - ... 'at least one field is missing an explicit value when it should have one: bar, baz' + ... 'at least one field is not set to a value other than None: bar, baz' + ... ) in str(e) + ... print('Validation failed') + Validation failed + >>> + >>> try: + ... MyModel(foo='special value', bar=None, baz=None) + ... except ValidationError as e: + ... assert ( + ... 'at least one field is not set to a value other than None: bar, baz' ... ) in str(e) ... print('Validation failed') Validation failed @@ -125,13 +134,15 @@ def validate_instance(self, model_instance: BaseModel) -> None: return missing_fields = [ - f for f in self.field_names if f not in model_instance.model_fields_set + f + for f in self.field_names + if not self._field_has_non_none_value(model_instance, f) ] if missing_fields: raise ValueError( - f"at least one field is missing an explicit value when it should have one: {', '.join(missing_fields)} - " - f"these field value(s) are required because {self.__condition} is true` (`{self.name}`)" + f"at least one field is not set to a value other than None: {', '.join(missing_fields)} - " + f"these field(s) are required because {self.__condition} is true (`{self.name}`)" ) @override @@ -140,8 +151,9 @@ def edit_config(self, model_class: type[BaseModel], config: ConfigDict) -> None: json_schema = get_static_json_schema_extra(config) + aliases = [apply_alias(model_class, f) for f in self.field_names] put_if( json_schema, self.__condition.json_schema(model_class), - {"required": [apply_alias(model_class, f) for f in self.field_names]}, + required_non_null(aliases), ) diff --git a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py index 96d5c40a1..090993442 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_forbid_if.py @@ -6,6 +6,7 @@ from util import assert_subset from overture.schema.system import create_model +from overture.schema.system._json_schema import required_non_null from overture.schema.system.model_constraint import ( Condition, FieldEqCondition, @@ -77,7 +78,8 @@ class TestModel(BaseModel): constraint.validate_class(TestModel) with pytest.raises( - ValueError, match="at least one field has an explicit value when it should not" + ValueError, + match="at least one field is set to a value other than None when it must not be", ): constraint.validate_instance(model_instance) @@ -105,6 +107,18 @@ class TestModel(BaseModel): constraint.validate_instance(TestModel(baz=42)) +def test_valid_model_instance_fields_explicitly_none_when_condition_true() -> None: + """Setting a forbidden field to None does not violate the prohibition.""" + + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int + + constraint = ForbidIfConstraint(["foo", "bar"], FieldEqCondition("baz", 42)) + constraint.validate_instance(TestModel(foo=None, bar=None, baz=42)) + + @pytest.mark.parametrize("field_names", [["foo"], ["bar"], ["foo", "bar"]]) def test_valid_model_instance_condition_false(field_names: list[str]) -> None: class TestModel(BaseModel): @@ -126,7 +140,7 @@ class TestModel(BaseModel): actual = TestModel.model_json_schema() expect = { "if": {"properties": {"qux": {"const": 42}}}, - "then": {"not": {"required": ["foo", "baz"]}}, + "then": {"not": required_non_null(["foo", "baz"])}, } assert expect == TestModel.model_config["json_schema_extra"] assert_subset(expect, actual, "expect", "actual") @@ -139,7 +153,7 @@ class TestModel(BaseModel): None, { "if": {"not": {"properties": {"corge": {"const": 42}}}}, - "then": {"not": {"required": ["bar", "baz"]}}, + "then": {"not": required_non_null(["bar", "baz"])}, }, ), ( @@ -147,7 +161,7 @@ class TestModel(BaseModel): { "random": "value", "if": {"not": {"properties": {"corge": {"const": 42}}}}, - "then": {"not": {"required": ["bar", "baz"]}}, + "then": {"not": required_non_null(["bar", "baz"])}, }, ), ( @@ -157,7 +171,7 @@ class TestModel(BaseModel): {"if": 123}, { "if": {"not": {"properties": {"corge": {"const": 42}}}}, - "then": {"not": {"required": ["bar", "baz"]}}, + "then": {"not": required_non_null(["bar", "baz"])}, }, ] }, diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py index 5209c06b0..6c28dec04 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_any_of.py @@ -3,6 +3,7 @@ from pydantic.json_schema import JsonDict from util import assert_subset +from overture.schema.system._json_schema import required_non_null from overture.schema.system.model_constraint import ( ModelConstraint, RequireAnyOfConstraint, @@ -43,7 +44,8 @@ class TestModel2(BaseModel): RequireAnyOfConstraint("foo", "bar").validate_class(TestModel2) -def test_error_invalid_model_instance() -> None: +@pytest.mark.parametrize("kwargs", [{}, {"foo": None, "bar": None}]) +def test_error_no_non_null_value(kwargs: dict[str, object]) -> None: @require_any_of("foo", "bar") class TestModel(BaseModel): foo: int | None = None @@ -51,9 +53,9 @@ class TestModel(BaseModel): with pytest.raises( ValidationError, - match="at least one of these fields must be explicitly set, but none are: foo, bar", + match="at least one of these fields must be set to a value other than None, but none are: foo, bar", ): - TestModel() + TestModel(**kwargs) @pytest.mark.parametrize("foo,bar", [(42, "hello"), (42, None), (None, "hello")]) @@ -73,7 +75,12 @@ class TestModel(BaseModel): bar: str | None = Field(default=None, alias="baz") actual = TestModel.model_json_schema() - expect = {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]} + expect = { + "anyOf": [ + required_non_null(["foo"]), + required_non_null(["baz"]), + ] + } assert expect == TestModel.model_config["json_schema_extra"] assert_subset(expect, actual, "expect", "actual") @@ -81,13 +88,26 @@ class TestModel(BaseModel): @pytest.mark.parametrize( "base_json_schema,expect", [ - (None, {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]}), + ( + None, + { + "anyOf": [ + required_non_null(["foo"]), + required_non_null(["baz"]), + ] + }, + ), ( {"anyOf": "anything"}, { "allOf": [ {"anyOf": "anything"}, - {"anyOf": [{"required": ["foo"]}, {"required": ["baz"]}]}, + { + "anyOf": [ + required_non_null(["foo"]), + required_non_null(["baz"]), + ] + }, ] }, ), diff --git a/packages/overture-schema-system/tests/model_constraint/test_require_if.py b/packages/overture-schema-system/tests/model_constraint/test_require_if.py index b3ec64705..2f6f16471 100644 --- a/packages/overture-schema-system/tests/model_constraint/test_require_if.py +++ b/packages/overture-schema-system/tests/model_constraint/test_require_if.py @@ -6,6 +6,7 @@ from util import assert_subset from overture.schema.system import create_model +from overture.schema.system._json_schema import required_non_null from overture.schema.system.model_constraint import ( Condition, FieldEqCondition, @@ -78,7 +79,7 @@ class TestModel(BaseModel): constraint.validate_class(TestModel) with pytest.raises( ValueError, - match="at least one field is missing an explicit value when it should have one:", + match="at least one field is not set to a value other than None:", ): constraint.validate_instance(model_instance) @@ -117,6 +118,20 @@ class TestModel(BaseModel): constraint.validate_instance(TestModel(bar=41, baz=42)) +def test_error_fields_explicitly_none_when_condition_true() -> None: + class TestModel(BaseModel): + foo: int | None = None + bar: int | None = None + baz: int + + constraint = RequireIfConstraint(["foo", "bar"], FieldEqCondition("baz", 42)) + with pytest.raises( + ValueError, + match="at least one field is not set to a value other than None", + ): + constraint.validate_instance(TestModel(foo=None, bar=None, baz=42)) + + def test_model_json_schema_no_model_config() -> None: @require_if(["foo", "baz"], FieldEqCondition("qux", 42)) class TestModel(BaseModel): @@ -127,7 +142,7 @@ class TestModel(BaseModel): actual = TestModel.model_json_schema() expect = { "if": {"properties": {"corge": {"const": 42}}}, - "then": {"required": ["bar", "baz"]}, + "then": required_non_null(["bar", "baz"]), } assert expect == TestModel.model_config["json_schema_extra"] assert_subset(expect, actual, "expect", "actual") @@ -140,7 +155,7 @@ class TestModel(BaseModel): None, { "if": {"not": {"properties": {"qux": {"const": 42}}}}, - "then": {"required": ["foo", "baz"]}, + "then": required_non_null(["foo", "baz"]), }, ), ( @@ -148,7 +163,7 @@ class TestModel(BaseModel): { "random": "value", "if": {"not": {"properties": {"qux": {"const": 42}}}}, - "then": {"required": ["foo", "baz"]}, + "then": required_non_null(["foo", "baz"]), }, ), ( @@ -158,7 +173,7 @@ class TestModel(BaseModel): {"if": 123}, { "if": {"not": {"properties": {"qux": {"const": 42}}}}, - "then": {"required": ["foo", "baz"]}, + "then": required_non_null(["foo", "baz"]), }, ] }, diff --git a/packages/overture-schema-system/tests/test___json_schema.py b/packages/overture-schema-system/tests/test___json_schema.py index 85769b805..e820f17e8 100644 --- a/packages/overture-schema-system/tests/test___json_schema.py +++ b/packages/overture-schema-system/tests/test___json_schema.py @@ -14,6 +14,7 @@ put_one_of, put_properties, put_required, + required_non_null, try_move, ) @@ -561,3 +562,44 @@ def test_try_move_missing_key() -> None: assert {"foo": "bar"} == src assert {} == dst + + +#################################################################################################### +# required_non_null # +#################################################################################################### + + +@pytest.mark.parametrize( + "aliases,expect", + [ + ( + ["foo"], + { + "required": ["foo"], + "properties": {"foo": {"not": {"type": "null"}}}, + }, + ), + ( + ["foo", "bar"], + { + "required": ["foo", "bar"], + "properties": { + "foo": {"not": {"type": "null"}}, + "bar": {"not": {"type": "null"}}, + }, + }, + ), + ], +) +def test_required_non_null_success(aliases: list[str], expect: JsonSchemaValue) -> None: + assert expect == required_non_null(aliases) + + +def test_required_non_null_error_empty() -> None: + with pytest.raises(ValueError, match="`operands` cannot be empty"): + required_non_null([]) + + +def test_required_non_null_error_not_list() -> None: + with pytest.raises(TypeError, match="`operands` must be a `list`"): + required_non_null(cast(list[str], "foo")) diff --git a/packages/overture-schema-system/tests/test_feature.py b/packages/overture-schema-system/tests/test_feature.py index 49dfbe85f..394d8bdea 100644 --- a/packages/overture-schema-system/tests/test_feature.py +++ b/packages/overture-schema-system/tests/test_feature.py @@ -1510,8 +1510,14 @@ class PropertiesObjectConstraintFeature(Feature): "properties": { "required": ["baz"], "anyOf": [ - {"required": ["foo"]}, - {"required": ["bar"]}, + { + "required": ["foo"], + "properties": {"foo": {"not": {"type": "null"}}}, + }, + { + "required": ["bar"], + "properties": {"bar": {"not": {"type": "null"}}}, + }, ], "if": { "properties": { @@ -1521,7 +1527,10 @@ class PropertiesObjectConstraintFeature(Feature): }, }, "then": { - "not": {"required": ["bar"]}, + "not": { + "required": ["bar"], + "properties": {"bar": {"not": {"type": "null"}}}, + }, }, }, }, @@ -1556,12 +1565,18 @@ class MixedConstraintFeature(Feature): "properties", ], "anyOf": [ - {"required": ["bbox"]}, + { + "required": ["bbox"], + "properties": {"bbox": {"not": {"type": "null"}}}, + }, { "properties": { "properties": { "type": "object", - "required": ["foo"], + "properties": { + "foo": {"not": {"type": "null"}}, + "properties": {"type": "object", "required": ["foo"]}, + }, } }, }, @@ -1569,7 +1584,13 @@ class MixedConstraintFeature(Feature): "properties": { "properties": { "type": "object", - "required": ["garply"], + "properties": { + "garply": {"not": {"type": "null"}}, + "properties": { + "type": "object", + "required": ["garply"], + }, + }, }, }, }, @@ -1591,9 +1612,17 @@ class MixedConstraintFeature(Feature): "then": { "required": ["id"], "properties": { + "id": {"not": {"type": "null"}}, "properties": { "type": "object", - "required": ["foo", "qux"], + "properties": { + "foo": {"not": {"type": "null"}}, + "qux": {"not": {"type": "null"}}, + "properties": { + "type": "object", + "required": ["foo", "qux"], + }, + }, }, }, }, @@ -1616,7 +1645,14 @@ class MixedConstraintFeature(Feature): "properties": { "properties": { "type": "object", - "required": ["foo", "type"], + "properties": { + "foo": {"not": {"type": "null"}}, + "type": {"not": {"type": "null"}}, + "properties": { + "type": "object", + "required": ["foo", "type"], + }, + }, } } }, diff --git a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json index b863d3b3a..2218297ef 100644 --- a/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json +++ b/packages/overture-schema-transportation-theme/tests/segment_baseline_schema.json @@ -100,11 +100,25 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "labels": { + "not": { + "type": "null" + } + } + }, "required": [ "labels" ] }, { + "properties": { + "symbols": { + "not": { + "type": "null" + } + } + }, "required": [ "symbols" ] @@ -1194,11 +1208,25 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "max_speed": { + "not": { + "type": "null" + } + } + }, "required": [ "max_speed" ] }, { + "properties": { + "min_speed": { + "not": { + "type": "null" + } + } + }, "required": [ "min_speed" ] @@ -1715,31 +1743,73 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "heading": { + "not": { + "type": "null" + } + } + }, "required": [ "heading" ] }, { + "properties": { + "during": { + "not": { + "type": "null" + } + } + }, "required": [ "during" ] }, { + "properties": { + "mode": { + "not": { + "type": "null" + } + } + }, "required": [ "mode" ] }, { + "properties": { + "using": { + "not": { + "type": "null" + } + } + }, "required": [ "using" ] }, { + "properties": { + "recognized": { + "not": { + "type": "null" + } + } + }, "required": [ "recognized" ] }, { + "properties": { + "vehicle": { + "not": { + "type": "null" + } + } + }, "required": [ "vehicle" ] @@ -1836,31 +1906,73 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "heading": { + "not": { + "type": "null" + } + } + }, "required": [ "heading" ] }, { + "properties": { + "during": { + "not": { + "type": "null" + } + } + }, "required": [ "during" ] }, { + "properties": { + "mode": { + "not": { + "type": "null" + } + } + }, "required": [ "mode" ] }, { + "properties": { + "using": { + "not": { + "type": "null" + } + } + }, "required": [ "using" ] }, { + "properties": { + "recognized": { + "not": { + "type": "null" + } + } + }, "required": [ "recognized" ] }, { + "properties": { + "vehicle": { + "not": { + "type": "null" + } + } + }, "required": [ "vehicle" ] @@ -1942,31 +2054,73 @@ "additionalProperties": false, "anyOf": [ { + "properties": { + "heading": { + "not": { + "type": "null" + } + } + }, "required": [ "heading" ] }, { + "properties": { + "during": { + "not": { + "type": "null" + } + } + }, "required": [ "during" ] }, { + "properties": { + "mode": { + "not": { + "type": "null" + } + } + }, "required": [ "mode" ] }, { + "properties": { + "using": { + "not": { + "type": "null" + } + } + }, "required": [ "using" ] }, { + "properties": { + "recognized": { + "not": { + "type": "null" + } + } + }, "required": [ "recognized" ] }, { + "properties": { + "vehicle": { + "not": { + "type": "null" + } + } + }, "required": [ "vehicle" ]