Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion dotnet/src/JsonStructure/Validation/SchemaValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions go/schema_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ func (ctx *schemaValidationContext) validatePrimitiveConstraints(typeStr string,
// Validate numeric constraints
if isNumericType(typeStr) {
ctx.validateNumericConstraints(schema, path)
ctx.validateUnitsKeywords(schema, path)
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions go/validators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 26 additions & 2 deletions perl/lib/JSON/Structure/SchemaValidator.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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} ) {
Expand Down
31 changes: 31 additions & 0 deletions perl/t/02_schema_validator.t
Original file line number Diff line number Diff line change
Expand Up @@ -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();

12 changes: 12 additions & 0 deletions php/src/JsonStructure/SchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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'];
Expand Down
1 change: 1 addition & 0 deletions php/src/JsonStructure/Types.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ final class Types
'JSONStructureImport',
'JSONStructureAlternateNames',
'JSONStructureUnits',
'JSONStructureRelations',
'JSONStructureConditionalComposition',
'JSONStructureValidation',
];
Expand Down
Loading
Loading