diff --git a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs index 0c3c088..c50324c 100644 --- a/dotnet/src/JsonStructure/Validation/SchemaValidator.cs +++ b/dotnet/src/JsonStructure/Validation/SchemaValidator.cs @@ -83,7 +83,9 @@ public sealed class SchemaValidator // Alternate names "altnames", // Units - "unit" + "unit", "ucumUnit", + // Relations + "identity", "relations", "targettype", "cardinality", "scope", "qualifiertype" }; private static readonly Regex NamespacePattern = new( @@ -1131,6 +1133,10 @@ private void ValidateCrossTypeConstraints(JsonObject schema, string? typeStr, st AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'exclusiveMinimum' constraint is only valid for numeric types, not '{typeStr}'", AppendPath(path, "exclusiveMinimum")); if (schema.ContainsKey("exclusiveMaximum")) AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'exclusiveMaximum' constraint is only valid for numeric types, not '{typeStr}'", AppendPath(path, "exclusiveMaximum")); + if (schema.ContainsKey("unit")) + AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'unit' keyword is only valid for numeric types, not '{typeStr}'", AppendPath(path, "unit")); + if (schema.ContainsKey("ucumUnit")) + AddError(result, ErrorCodes.SchemaConstraintInvalidForType, $"'ucumUnit' keyword is only valid for numeric types, not '{typeStr}'", AppendPath(path, "ucumUnit")); } // Array constraints on non-array types @@ -1568,6 +1574,16 @@ private void ValidateNumericSchema(JsonObject schema, string path, ValidationRes ValidatePositiveNumber(schema, "multipleOf", path, result); AddExtensionKeywordWarning(result, "multipleOf", path); } + + if (schema.TryGetPropertyValue("unit", out var unitValue)) + { + ValidateStringProperty(unitValue, "unit", path, result); + } + + if (schema.TryGetPropertyValue("ucumUnit", out var ucumUnitValue)) + { + ValidateStringProperty(ucumUnitValue, "ucumUnit", path, result); + } } private void ValidateEnum(JsonNode? value, string path, ValidationResult result) diff --git a/dotnet/tests/JsonStructure.Tests/Validation/AdditionalValidationTests.cs b/dotnet/tests/JsonStructure.Tests/Validation/AdditionalValidationTests.cs index 835db91..365df52 100644 --- a/dotnet/tests/JsonStructure.Tests/Validation/AdditionalValidationTests.cs +++ b/dotnet/tests/JsonStructure.Tests/Validation/AdditionalValidationTests.cs @@ -4652,6 +4652,34 @@ public void SchemaValidator_Altnames_Valid() var result = validator.Validate(schema); result.IsValid.ShouldBeTrue(); } + + [Fact] + public void SchemaValidator_UcumUnit_ValidatesNumericAnnotations() + { + var validator = new SchemaValidator(); + var validSchema = new JsonObject + { + ["$id"] = "https://example.com/measurement", + ["type"] = "number", + ["name"] = "Measurement", + ["unit"] = "meters", + ["ucumUnit"] = "m" + }; + + validator.Validate(validSchema).IsValid.ShouldBeTrue(); + + var invalidSchema = new JsonObject + { + ["$id"] = "https://example.com/measurement-text", + ["type"] = "string", + ["name"] = "MeasurementText", + ["ucumUnit"] = "m" + }; + + var invalidResult = validator.Validate(invalidSchema); + invalidResult.IsValid.ShouldBeFalse(); + invalidResult.Errors.ShouldContain(error => error.Path != null && error.Path.Contains("ucumUnit")); + } #endregion diff --git a/go/schema_validator.go b/go/schema_validator.go index aafbdbb..5e74ff0 100644 --- a/go/schema_validator.go +++ b/go/schema_validator.go @@ -580,6 +580,7 @@ func (ctx *schemaValidationContext) validatePrimitiveConstraints(typeStr string, // Validate numeric constraints if isNumericType(typeStr) { ctx.validateNumericConstraints(schema, path) + ctx.validateUnitsKeywords(schema, path) } } @@ -659,6 +660,16 @@ func (ctx *schemaValidationContext) validateNumericConstraints(schema map[string } } +func (ctx *schemaValidationContext) validateUnitsKeywords(schema map[string]interface{}, path string) { + for _, key := range []string{"unit", "ucumUnit"} { + if value, ok := schema[key]; ok { + if _, isString := value.(string); !isString { + ctx.addError(path+"/"+key, key+" must be a string", SchemaKeywordInvalidType) + } + } + } +} + func (ctx *schemaValidationContext) validateArrayConstraints(schema map[string]interface{}, path string) { if minItems, ok := schema["minItems"]; ok { minItemsNum, isNum := minItems.(float64) @@ -773,6 +784,7 @@ func (ctx *schemaValidationContext) validateConditionalKeywords(schema map[strin func (ctx *schemaValidationContext) validateConstraintTypeMatch(typeStr string, schema map[string]interface{}, path string) { stringOnlyConstraints := []string{"minLength", "maxLength", "pattern"} numericOnlyConstraints := []string{"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"} + numericAnnotationKeywords := []string{"unit", "ucumUnit"} // Check string constraints on non-string types for _, constraint := range stringOnlyConstraints { @@ -787,6 +799,12 @@ func (ctx *schemaValidationContext) validateConstraintTypeMatch(typeStr string, ctx.addError(path+"/"+constraint, fmt.Sprintf("%s constraint is only valid for numeric types, not %s", constraint, typeStr), SchemaConstraintInvalidForType) } } + + for _, keyword := range numericAnnotationKeywords { + if _, ok := schema[keyword]; ok && !isNumericType(typeStr) { + ctx.addError(path+"/"+keyword, fmt.Sprintf("%s keyword is only valid for numeric types, not %s", keyword, typeStr), SchemaConstraintInvalidForType) + } + } } func (ctx *schemaValidationContext) validateExtends(extendsVal interface{}, path string) { diff --git a/go/validators_test.go b/go/validators_test.go index 4c4defa..119fbb1 100644 --- a/go/validators_test.go +++ b/go/validators_test.go @@ -756,6 +756,44 @@ func TestSchemaValidatorUnionMissingRef(t *testing.T) { } } +func TestSchemaValidatorUcumUnitKeyword(t *testing.T) { + validator := NewSchemaValidator(&SchemaValidatorOptions{Extended: true}) + + validSchema := map[string]interface{}{ + "$id": "urn:example:measurement", + "name": "Measurement", + "type": "number", + "unit": "meters", + "ucumUnit": "m", + } + + if result := validator.Validate(validSchema); !result.IsValid { + t.Fatalf("Expected valid numeric schema with unit and ucumUnit, got errors: %v", result.Errors) + } + + invalidTypeSchema := map[string]interface{}{ + "$id": "urn:example:measurement-text", + "name": "MeasurementText", + "type": "string", + "ucumUnit": "m", + } + + if result := validator.Validate(invalidTypeSchema); result.IsValid { + t.Fatalf("Expected non-numeric ucumUnit schema to be invalid") + } + + invalidValueSchema := map[string]interface{}{ + "$id": "urn:example:measurement-value", + "name": "MeasurementValue", + "type": "number", + "ucumUnit": float64(1), + } + + if result := validator.Validate(invalidValueSchema); result.IsValid { + t.Fatalf("Expected non-string ucumUnit schema to be invalid") + } +} + // TestWarnOnUnusedExtensionKeywordsDefault tests that warnings are emitted by default for extension keywords without $uses. func TestWarnOnUnusedExtensionKeywordsDefault(t *testing.T) { validator := NewSchemaValidator(&SchemaValidatorOptions{}) diff --git a/java/src/main/java/org/json_structure/validation/SchemaValidator.java b/java/src/main/java/org/json_structure/validation/SchemaValidator.java index c907af6..7aadddc 100644 --- a/java/src/main/java/org/json_structure/validation/SchemaValidator.java +++ b/java/src/main/java/org/json_structure/validation/SchemaValidator.java @@ -100,7 +100,9 @@ public final class SchemaValidator { // Alternate names "altnames", // Units - "unit" + "unit", "ucumUnit", + // Relations + "identity", "relations", "targettype", "cardinality", "scope", "qualifiertype" ); private static final Pattern NAMESPACE_PATTERN = Pattern.compile( @@ -637,6 +639,14 @@ private void validateTypeConstraintCompatibility(ObjectNode schema, String typeS addError(result, ErrorCodes.SCHEMA_CONSTRAINT_INVALID_FOR_TYPE, "'multipleOf' constraint is only valid for numeric types, not '" + typeStr + "'", appendPath(path, "multipleOf")); } + if (schema.has("unit")) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_INVALID_FOR_TYPE, "'unit' keyword is only valid for numeric types, not '" + typeStr + "'", + appendPath(path, "unit")); + } + if (schema.has("ucumUnit")) { + addError(result, ErrorCodes.SCHEMA_CONSTRAINT_INVALID_FOR_TYPE, "'ucumUnit' keyword is only valid for numeric types, not '" + typeStr + "'", + appendPath(path, "ucumUnit")); + } } // String constraints on non-string types @@ -1222,6 +1232,14 @@ private void validateNumericSchema(ObjectNode schema, String path, ValidationRes validateNumber(schema, "exclusiveMinimum", path, result); validateNumber(schema, "exclusiveMaximum", path, result); validatePositiveNumber(schema, "multipleOf", path, result); + + if (schema.has("unit")) { + validateStringProperty(schema.get("unit"), "unit", path, result); + } + + if (schema.has("ucumUnit")) { + validateStringProperty(schema.get("ucumUnit"), "ucumUnit", path, result); + } // Check minimum <= maximum if (schema.has("minimum") && schema.has("maximum")) { diff --git a/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java b/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java index 270db43..5434662 100644 --- a/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java +++ b/java/src/test/java/org/json_structure/validation/AdditionalValidationTests.java @@ -2121,6 +2121,39 @@ void shouldValidateSchemaWithUnit() { ValidationResult result = validator.validate(schema); assertThat(result.isValid()).isTrue(); } + + @Test + @DisplayName("Should validate schema with ucumUnit alongside unit") + void shouldValidateSchemaWithUcumUnit() { + String schema = """ + { + "$id": "https://test.example.com/schema/ucum-unit", + "name": "UcumUnitSchema", + "type": "number", + "unit": "meters", + "ucumUnit": "m" + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isTrue(); + } + + @Test + @DisplayName("Should reject ucumUnit on non-numeric schema") + void shouldRejectUcumUnitOnNonNumericSchema() { + String schema = """ + { + "$id": "https://test.example.com/schema/ucum-unit-invalid", + "name": "InvalidUcumUnitSchema", + "type": "string", + "ucumUnit": "m" + } + """; + + ValidationResult result = validator.validate(schema); + assertThat(result.isValid()).isFalse(); + } // Note: Unknown keywords like "deprecated" are ignored per JSON Structure spec // (no SCHEMA_UNKNOWN_KEYWORD error code exists - validators should not reject unknown keywords) diff --git a/perl/lib/JSON/Structure/SchemaValidator.pm b/perl/lib/JSON/Structure/SchemaValidator.pm index 2706f2f..c610476 100644 --- a/perl/lib/JSON/Structure/SchemaValidator.pm +++ b/perl/lib/JSON/Structure/SchemaValidator.pm @@ -64,7 +64,8 @@ my %RESERVED_KEYWORDS = map { $_ => 1 } qw( $offers abstract additionalProperties const default description enum examples format items maxLength name precision properties required scale type - values choices selector tuple + values choices selector tuple unit ucumUnit + identity relations targettype cardinality scope qualifiertype ); # Extended keywords for conditional composition @@ -112,7 +113,7 @@ my %VALID_FORMATS = map { $_ => 1 } qw( # Known extensions my %KNOWN_EXTENSIONS = map { $_ => 1 } qw( JSONStructureImport JSONStructureAlternateNames JSONStructureUnits - JSONStructureConditionalComposition JSONStructureValidation + JSONStructureRelations JSONStructureConditionalComposition JSONStructureValidation ); sub new { @@ -1384,6 +1385,16 @@ sub _check_constraint_type_mismatch { } } + for my $keyword (qw(unit ucumUnit)) { + if ( exists $schema->{$keyword} && !$is_numeric ) { + $self->_add_error( + SCHEMA_CONSTRAINT_TYPE_MISMATCH, + "Keyword '$keyword' is only valid for numeric types, not '$type'", + "$path/$keyword" + ); + } + } + # String constraints can only be on string types my @string_types = qw(string date time datetime duration uri base64 binary uuid jsonpointer name); @@ -1427,6 +1438,19 @@ sub _validate_extended_keywords { } } + for my $keyword (qw(unit ucumUnit)) { + if ( exists $schema->{$keyword} ) { + my $value = $schema->{$keyword}; + if ( !defined $value || ref($value) ) { + $self->_add_error( + SCHEMA_KEYWORD_INVALID_TYPE, + "$keyword must be a string", + "$path/$keyword" + ); + } + } + } + # Check min/max relationships if ( exists $schema->{minimum} && exists $schema->{maximum} ) { if ( $schema->{minimum} > $schema->{maximum} ) { diff --git a/perl/t/02_schema_validator.t b/perl/t/02_schema_validator.t index ba8a1da..89ca4c7 100644 --- a/perl/t/02_schema_validator.t +++ b/perl/t/02_schema_validator.t @@ -337,5 +337,36 @@ subtest 'Source location tracking' => sub { ok($error->location->is_known, 'error has location') or diag("Location: " . $error->location->to_string); }; +subtest 'ucumUnit keyword validation' => sub { + my $validator = JSON::Structure::SchemaValidator->new( extended => 1 ); + + my $valid_schema = basic_schema( + type => 'number', + unit => 'meters', + ucumUnit => 'm', + ); + delete $valid_schema->{properties}; + my $result = $validator->validate($valid_schema); + ok($result->is_valid, 'numeric schema may use unit and ucumUnit') or diag(join("\n", map { $_->to_string } @{$result->errors})); + + my $invalid_type_schema = basic_schema( + type => 'string', + ucumUnit => 'm', + ); + delete $invalid_type_schema->{properties}; + $result = $validator->validate($invalid_type_schema); + ok(!$result->is_valid, 'ucumUnit on non-numeric schema is invalid'); + ok(has_error_code($result, SCHEMA_CONSTRAINT_TYPE_MISMATCH), 'reports numeric type mismatch for ucumUnit'); + + my $invalid_value_schema = basic_schema( + type => 'number', + ucumUnit => [], + ); + delete $invalid_value_schema->{properties}; + $result = $validator->validate($invalid_value_schema); + ok(!$result->is_valid, 'ucumUnit must be a string'); + ok(has_error_code($result, SCHEMA_KEYWORD_INVALID_TYPE), 'reports invalid ucumUnit type'); +}; + done_testing(); diff --git a/php/src/JsonStructure/SchemaValidator.php b/php/src/JsonStructure/SchemaValidator.php index 04d4fef..eec318c 100644 --- a/php/src/JsonStructure/SchemaValidator.php +++ b/php/src/JsonStructure/SchemaValidator.php @@ -505,6 +505,7 @@ private function checkExtendedValidationKeywords(array $obj, string $path): void // Check for constraint type mismatches $stringConstraints = ['minLength', 'maxLength', 'pattern']; $numericConstraints = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']; + $numericAnnotationKeywords = ['unit', 'ucumUnit']; $arrayConstraints = ['minItems', 'maxItems', 'uniqueItems', 'contains', 'minContains', 'maxContains']; // Check string constraints on non-string types @@ -523,6 +524,11 @@ private function checkExtendedValidationKeywords(array $obj, string $path): void $this->addError("'{$key}' constraint is only valid for numeric types, not '{$tval}'.", "{$path}/{$key}"); } } + foreach ($numericAnnotationKeywords as $key) { + if (isset($obj[$key])) { + $this->addError("'{$key}' keyword is only valid for numeric types, not '{$tval}'.", "{$path}/{$key}"); + } + } } // Check array constraints on non-array types @@ -575,6 +581,12 @@ private function checkNumericValidation(array $obj, string $path, string $typeNa } } + foreach (['unit', 'ucumUnit'] as $key) { + if (isset($obj[$key]) && !is_string($obj[$key])) { + $this->addError("'{$key}' must be a string.", "{$path}/{$key}"); + } + } + // Check minimum <= maximum if (isset($obj['minimum'], $obj['maximum'])) { $minVal = $obj['minimum']; diff --git a/php/src/JsonStructure/Types.php b/php/src/JsonStructure/Types.php index 0f7ce08..dbd481e 100644 --- a/php/src/JsonStructure/Types.php +++ b/php/src/JsonStructure/Types.php @@ -172,6 +172,7 @@ final class Types 'JSONStructureImport', 'JSONStructureAlternateNames', 'JSONStructureUnits', + 'JSONStructureRelations', 'JSONStructureConditionalComposition', 'JSONStructureValidation', ]; diff --git a/php/tests/SchemaValidatorTest.php b/php/tests/SchemaValidatorTest.php index 47d3bca..1d1cc59 100644 --- a/php/tests/SchemaValidatorTest.php +++ b/php/tests/SchemaValidatorTest.php @@ -656,4 +656,33 @@ public function testSourceLocationTracking(): void $location = $errors[0]->location; $this->assertNotNull($location); } + + public function testUcumUnitKeyword(): void + { + $validSchema = [ + '$id' => 'https://example.com/measurement.struct.json', + 'name' => 'Measurement', + 'type' => 'number', + 'unit' => 'meters', + 'ucumUnit' => 'm', + ]; + + $this->assertCount(0, $this->validator->validate($validSchema)); + + $invalidTypeSchema = [ + '$id' => 'https://example.com/not-numeric.struct.json', + 'name' => 'NotNumeric', + 'type' => 'string', + 'ucumUnit' => 'm', + ]; + $this->assertGreaterThan(0, count($this->validator->validate($invalidTypeSchema))); + + $invalidValueSchema = [ + '$id' => 'https://example.com/bad-ucum.struct.json', + 'name' => 'BadUcum', + 'type' => 'number', + 'ucumUnit' => 123, + ]; + $this->assertGreaterThan(0, count($this->validator->validate($invalidValueSchema))); + } } diff --git a/python/src/json_structure/instance_validator.py b/python/src/json_structure/instance_validator.py index 84503a6..a32fd2a 100644 --- a/python/src/json_structure/instance_validator.py +++ b/python/src/json_structure/instance_validator.py @@ -13,7 +13,7 @@ Additionally, if the instance provides a "$uses" clause containing "JSONStructureConditionalComposition" and/or "JSONStructureValidation", the corresponding conditional composition and validation addin constraints are enforced. -Extensions such as "JSONStructureAlternateNames" or "JSONStructureUnits" are generally ignored for validation. +Extensions such as "JSONStructureAlternateNames", "JSONStructureUnits", or "JSONStructureRelations" are generally ignored for validation. Furthermore, when the root schema’s "$schema" equals "https://json-structure.org/meta/extended/v0/#" @@ -135,7 +135,8 @@ def validate_instance(self, instance, schema=None, path="#", meta=None): "JSONStructureConditionalComposition", "JSONStructureValidation", "JSONStructureUnits", - "JSONStructureAlternateNames" + "JSONStructureAlternateNames", + "JSONStructureRelations" ] schema.setdefault("$uses", []) for addin in all_addins: @@ -1227,7 +1228,7 @@ def _apply_uses(self, schema, instance): offers = self.root_schema.get("$offers", {}) merged = dict(schema) merged.setdefault("properties", {}) - for use in [u for u in uses if not u in ["JSONStructureValidation", "JSONStructureConditionalComposition", "JSONStructureAlternateNames", "JSONStructureUnits"]]: + for use in [u for u in uses if not u in ["JSONStructureValidation", "JSONStructureConditionalComposition", "JSONStructureAlternateNames", "JSONStructureUnits", "JSONStructureRelations"]]: if use not in offers: self.errors.append(f"Add-in '{use}' not offered in $offers") continue diff --git a/python/src/json_structure/schema_validator.py b/python/src/json_structure/schema_validator.py index 63d7d46..1e2ff03 100644 --- a/python/src/json_structure/schema_validator.py +++ b/python/src/json_structure/schema_validator.py @@ -47,7 +47,8 @@ class JSONStructureSchemaCoreValidator: "$offers", "abstract", "additionalProperties", "const", "default", "description", "enum", "examples", "format", "items", "maxLength", "name", "precision", "properties", "required", "scale", "type", - "values", "choices", "selector", "tuple" + "values", "choices", "selector", "tuple", "unit", "ucumUnit", + "identity", "relations", "targettype", "cardinality", "scope", "qualifiertype" } PRIMITIVE_TYPES = { "string", "number", "integer", "boolean", "null", "int8", "uint8", "int16", "uint16", @@ -77,7 +78,7 @@ class JSONStructureSchemaCoreValidator: # Extension names KNOWN_EXTENSIONS = { "JSONStructureImport", "JSONStructureAlternateNames", "JSONStructureUnits", - "JSONStructureConditionalComposition", "JSONStructureValidation" + "JSONStructureRelations", "JSONStructureConditionalComposition", "JSONStructureValidation" } def __init__(self, allow_dollar=False, allow_import=False, import_map=None, extended=False, external_schemas=None, warn_on_unused_extension_keywords=True, max_validation_depth=64): @@ -706,19 +707,23 @@ def _check_extended_validation_keywords(self, obj, path): # Check for constraint type mismatches string_constraints = ["minLength", "maxLength", "pattern"] numeric_constraints = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"] + numeric_annotation_keywords = ["unit", "ucumUnit"] array_constraints = ["minItems", "maxItems", "uniqueItems", "contains", "minContains", "maxContains"] - + # Check string constraints on non-string types if tval != "string": for key in string_constraints: if key in obj: self._err(f"'{key}' constraint is only valid for string type, not '{tval}'.", f"{path}/{key}") - + # Check numeric constraints on non-numeric types if tval not in numeric_types: for key in numeric_constraints: if key in obj: self._err(f"'{key}' constraint is only valid for numeric types, not '{tval}'.", f"{path}/{key}") + for key in numeric_annotation_keywords: + if key in obj: + self._err(f"'{key}' keyword is only valid for numeric types, not '{tval}'.", f"{path}/{key}") # Check array constraints on non-array types if tval not in array_types: @@ -729,6 +734,7 @@ def _check_extended_validation_keywords(self, obj, path): # Now validate the constraint values for matching types if tval in numeric_types: self._check_numeric_validation(obj, path, tval, validation_enabled) + self._check_units_keywords(obj, path) elif tval == "string": self._check_string_validation(obj, path, validation_enabled) elif tval in ["array", "set"]: @@ -771,6 +777,11 @@ def _check_numeric_validation(self, obj, path, type_name, validation_enabled=Tru if min_val > max_val: self._err("'minimum' cannot be greater than 'maximum'.", f"{path}") + def _check_units_keywords(self, obj, path): + for key in ["unit", "ucumUnit"]: + if key in obj and not isinstance(obj[key], str): + self._err(f"'{key}' must be a string.", f"{path}/{key}") + def _check_string_validation(self, obj, path, validation_enabled=True): """ Check string validation keywords. diff --git a/python/tests/test_schema_validator.py b/python/tests/test_schema_validator.py index f547756..3850567 100644 --- a/python/tests/test_schema_validator.py +++ b/python/tests/test_schema_validator.py @@ -1731,6 +1731,38 @@ def test_schema_not_dict(): assert len(errors) > 0 +def test_ucum_unit_keyword_support(): + valid_schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/measurement", + "name": "Measurement", + "type": "number", + "unit": "meters", + "ucumUnit": "m" + } + assert validate_json_structure_schema_core(valid_schema, json.dumps(valid_schema), extended=True) == [] + + invalid_type_schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/not-numeric", + "name": "NotNumeric", + "type": "string", + "ucumUnit": "m" + } + invalid_type_errors = validate_json_structure_schema_core(invalid_type_schema, json.dumps(invalid_type_schema), extended=True) + assert any("ucumUnit" in err for err in invalid_type_errors) + + invalid_value_schema = { + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/bad-ucum", + "name": "BadUcum", + "type": "number", + "ucumUnit": 123 + } + invalid_value_errors = validate_json_structure_schema_core(invalid_value_schema, json.dumps(invalid_value_schema), extended=True) + assert any("ucumUnit" in err for err in invalid_value_errors) + + def test_source_locator_for_errors(): """Test that source locator provides line/column info.""" from json_structure.schema_validator import JSONStructureSchemaCoreValidator diff --git a/rust/README.md b/rust/README.md index 479b071..d4a9488 100644 --- a/rust/README.md +++ b/rust/README.md @@ -180,7 +180,7 @@ Enable extensions using `$uses` in your schema: - **JSONStructureConditionalComposition**: Composition keywords (`allOf`, `anyOf`, `oneOf`, `not`, `if/then/else`) - **JSONStructureImport**: Schema imports (`$import`, `$importdefs`) - **JSONStructureAlternateNames**: Alternate property names (`altnames`) -- **JSONStructureUnits**: Unit annotations (`unit`) +- **JSONStructureUnits**: Unit annotations (`unit`, `ucumUnit`) ## Error Handling diff --git a/rust/src/schema_validator.rs b/rust/src/schema_validator.rs index 97e61dd..97c49cf 100644 --- a/rust/src/schema_validator.rs +++ b/rust/src/schema_validator.rs @@ -737,6 +737,22 @@ impl SchemaValidator { locator.get_location(&format!("{}/maximum", path)), )); } + if obj.contains_key("unit") && !is_numeric { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + format!("unit keyword cannot be used with type '{}'", type_name), + &format!("{}/unit", path), + locator.get_location(&format!("{}/unit", path)), + )); + } + if obj.contains_key("ucumUnit") && !is_numeric { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaConstraintTypeMismatch, + format!("ucumUnit keyword cannot be used with type '{}'", type_name), + &format!("{}/ucumUnit", path), + locator.get_location(&format!("{}/ucumUnit", path)), + )); + } // minLength/maxLength only apply to string if obj.contains_key("minLength") && !is_string { @@ -759,6 +775,7 @@ impl SchemaValidator { // Validate numeric constraint values if is_numeric { self.validate_numeric_constraints(obj, locator, result, path); + self.validate_units_keywords(obj, locator, result, path); } // Validate string constraint values @@ -823,6 +840,27 @@ impl SchemaValidator { } } + fn validate_units_keywords( + &self, + obj: &serde_json::Map, + locator: &JsonSourceLocator, + result: &mut ValidationResult, + path: &str, + ) { + for keyword in ["unit", "ucumUnit"] { + if let Some(value) = obj.get(keyword) { + if !value.is_string() { + result.add_error(ValidationError::schema_error( + SchemaErrorCode::SchemaKeywordInvalidType, + format!("{} must be a string", keyword), + &format!("{}/{}", path, keyword), + locator.get_location(&format!("{}/{}", path, keyword)), + )); + } + } + } + } + /// Validates string constraints (minLength/maxLength relationships). fn validate_string_constraints( &self, diff --git a/rust/src/types.rs b/rust/src/types.rs index 74b5078..1938421 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -343,7 +343,9 @@ pub const SCHEMA_KEYWORDS: &[&str] = &[ // Alternate names "altnames", // Units - "unit", + "unit", "ucumUnit", + // Relations + "identity", "relations", "targettype", "cardinality", "scope", "qualifiertype", ]; /// Validation extension keywords that require JSONStructureValidation. @@ -374,6 +376,7 @@ pub const KNOWN_EXTENSIONS: &[&str] = &[ "JSONStructureImport", "JSONStructureAlternateNames", "JSONStructureUnits", + "JSONStructureRelations", "JSONStructureConditionalComposition", "JSONStructureValidation", ]; diff --git a/rust/tests/schema_validator_tests.rs b/rust/tests/schema_validator_tests.rs index b09a631..aa1fb47 100644 --- a/rust/tests/schema_validator_tests.rs +++ b/rust/tests/schema_validator_tests.rs @@ -715,6 +715,39 @@ fn test_valid_offers_extension() { assert!(result.is_valid(), "$offers extension should be valid"); } +#[test] +fn test_ucum_unit_keyword_support() { + let validator = SchemaValidator::new(); + + let valid_schema = r##"{ + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/schema/measurement", + "name": "Measurement", + "type": "number", + "unit": "meters", + "ucumUnit": "m" + }"##; + assert!(validator.validate(valid_schema).is_valid(), "numeric schema should allow unit and ucumUnit"); + + let invalid_type_schema = r##"{ + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/schema/not-numeric", + "name": "NotNumeric", + "type": "string", + "ucumUnit": "m" + }"##; + assert!(!validator.validate(invalid_type_schema).is_valid(), "ucumUnit should be rejected on non-numeric schemas"); + + let invalid_value_schema = r##"{ + "$schema": "https://json-structure.org/meta/core/v0/#", + "$id": "https://example.com/schema/bad-ucum", + "name": "BadUcum", + "type": "number", + "ucumUnit": 1 + }"##; + assert!(!validator.validate(invalid_value_schema).is_valid(), "ucumUnit should require a string value"); +} + // ============================================================================= // Valid: Altnames // ============================================================================= diff --git a/swift/Sources/JSONStructure/SchemaValidator.swift b/swift/Sources/JSONStructure/SchemaValidator.swift index 7d7d7c7..e9112fd 100644 --- a/swift/Sources/JSONStructure/SchemaValidator.swift +++ b/swift/Sources/JSONStructure/SchemaValidator.swift @@ -541,6 +541,7 @@ private final class ValidationEngine { // Validate numeric constraints if isNumericType(typeStr) { validateNumericConstraints(schema, path) + validateUnitsKeywords(schema, path) } } @@ -620,6 +621,14 @@ private final class ValidationEngine { } } + private func validateUnitsKeywords(_ schema: [String: Any], _ path: String) { + for key in ["unit", "ucumUnit"] { + if let value = schema[key], !(value is String) { + addError("\(path)/\(key)", "\(key) must be a string", schemaKeywordInvalidType) + } + } + } + private func validateArrayConstraints(_ schema: [String: Any], _ path: String) { if let minItems = schema["minItems"] { if let minItemsNum = toInt(minItems) { @@ -731,6 +740,7 @@ private final class ValidationEngine { private func validateConstraintTypeMatch(_ typeStr: String, _ schema: [String: Any], _ path: String) { let stringOnlyConstraints = ["minLength", "maxLength", "pattern"] let numericOnlyConstraints = ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"] + let numericAnnotationKeywords = ["unit", "ucumUnit"] // Check string constraints on non-string types for constraint in stringOnlyConstraints { @@ -745,6 +755,12 @@ private final class ValidationEngine { addError("\(path)/\(constraint)", "\(constraint) constraint is only valid for numeric types, not \(typeStr)", schemaConstraintInvalidForType) } } + + for keyword in numericAnnotationKeywords { + if schema[keyword] != nil && !isNumericType(typeStr) { + addError("\(path)/\(keyword)", "\(keyword) keyword is only valid for numeric types, not \(typeStr)", schemaConstraintInvalidForType) + } + } } private func validateExtends(_ extendsVal: Any, _ path: String) { diff --git a/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift b/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift index b95b540..5ccd0e5 100644 --- a/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift +++ b/swift/Tests/JSONStructureTests/AdditionalValidationTests.swift @@ -872,11 +872,13 @@ final class AdditionalValidationTests: XCTestCase { "properties": [ "velocity": [ "type": "number", - "unit": "m/s" + "unit": "m/s", + "ucumUnit": "m/s" ], "acceleration": [ "type": "number", - "unit": "m/s^2" + "unit": "m/s^2", + "ucumUnit": "m/s2" ] ] ] @@ -884,4 +886,17 @@ final class AdditionalValidationTests: XCTestCase { // Unit keyword should be accepted without error XCTAssertTrue(validator.validate(schema).isValid) } + + func testUcumUnitKeywordOnNonNumericSchema() throws { + let validator = SchemaValidator(options: SchemaValidatorOptions(extended: true)) + + let schema: [String: Any] = [ + "$id": "urn:test:ucum-invalid", + "name": "NotNumeric", + "type": "string", + "ucumUnit": "m" + ] + + XCTAssertFalse(validator.validate(schema).isValid) + } }