From d7359055ce8d6f1b8e3ebd13cc905054ccc2c7e1 Mon Sep 17 00:00:00 2001 From: Clemens Vasters Date: Mon, 8 Jun 2026 12:18:58 +0200 Subject: [PATCH] Add Relations extension support Add JSONStructureRelations to extension gate lists and register relations keywords (identity, relations, targettype, cardinality, scope, qualifiertype) across all SDK languages. Addresses json-structure/sdk#167 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Validation/SchemaValidator.cs | 18 +++++++++++- .../validation/SchemaValidator.java | 20 ++++++++++++- perl/lib/JSON/Structure/SchemaValidator.pm | 28 +++++++++++++++++-- php/src/JsonStructure/Types.php | 1 + .../src/json_structure/instance_validator.py | 7 +++-- python/src/json_structure/schema_validator.py | 19 ++++++++++--- rust/src/types.rs | 5 +++- 7 files changed, 86 insertions(+), 12 deletions(-) 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/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/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/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/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/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", ];