From e53c5b05b8a59fecfac876a51aee969f9a2daf24 Mon Sep 17 00:00:00 2001 From: ashfame Date: Thu, 4 Dec 2025 22:00:33 +0400 Subject: [PATCH 1/3] add failing test for invalid tuple data --- .../HumanFriendlySchemaValidatorTest.php | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php b/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php index 8079149e..bb7f6716 100644 --- a/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php +++ b/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php @@ -1643,4 +1643,77 @@ private function assertIsValid( $result, bool $shouldBeValid = true ) { private function assertIsInvalid( $result ) { $this->assertInstanceOf( ValidationError::class, $result ); } + + public function testItReturnsValidationErrorForInvalidTupleData() { + $schema_path = __DIR__ . '/../../../Versions/Version2/json-schema/schema-v2.json'; + $schema = json_decode( file_get_contents( $schema_path ), true ); + $this->assertIsArray( $schema, 'Failed to parse schema file: ' . $schema_path ); + + $invalid_blueprint_json = '{ + "version": 2, + "blueprintMeta": { + "name": "Invalid Blueprint - Wrong Post Type Format", + "description": "This blueprint has invalid post type definitions" + }, + "postTypes": { + "book": { + "label": 123, + "public": "not-a-boolean", + "hierarchical": "wrong-type", + "show_in_menu": {}, + "capability_type": [ + "one", + "two", + "three" + ], + "template": [ + "invalid-not-an-array", + [ + "valid-item-1" + ], + [ + "valid-item-2", + {} + ], + [ + 123, + "not-string-first-element" + ] + ], + "supports": [ + "title", + 123, + true + ] + } + }, + "content": [ + { + "type": "posts", + "source": [ + { + "post_title": "Test Post", + "post_content": "This is a test post." + } + ] + } + ] + }'; + $invalid_blueprint = json_decode( $invalid_blueprint_json ); + + $validator = new HumanFriendlySchemaValidator( $schema ); + try { + $error = $validator->validate( $invalid_blueprint ); + } catch ( UnsupportedSchemaException $e ) { + $this->fail( + 'The validator threw an UnsupportedSchemaException when it should have returned a ValidationError. Exception message: ' . $e->getMessage() + ); + } + + $this->assertInstanceOf( + ValidationError::class, + $error, + 'The validator should return a ValidationError, not throw an UnsupportedSchemaException exception or pass.' + ); + } } From dacaa3eb3fbe0611757018bed5065a132ea8cf0a Mon Sep 17 00:00:00 2001 From: ashfame Date: Thu, 4 Dec 2025 22:01:01 +0400 Subject: [PATCH 2/3] handle tuple validation --- .../class-humanfriendlyschemavalidator.php | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php b/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php index 41bbae29..ec940725 100644 --- a/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php +++ b/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php @@ -582,7 +582,7 @@ private function validate_type( array $path, $data, array $schema ): ?Validation } if ( isset( $schema['enum'] ) ) { foreach ( $schema['enum'] as $enum_value ) { - if ( ! $this->type_matches( $enum_value, $type ) ) { + if ( ! $this->type_matches_any( $enum_value, $type ) ) { throw new UnsupportedSchemaException( 'Enum value ' . json_encode( $enum_value ) . " does not match the declared type \"{$type}\"." ); @@ -731,10 +731,48 @@ private function validate_object( array $path, $data, array $schema ): ?Validati private function validate_array( array $path, array $data, array $schema ): ?ValidationError { $children_errors = array(); if ( isset( $schema['items'] ) ) { - foreach ( $data as $idx => $item ) { - $error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'] ); - if ( $error ) { - $children_errors[] = $error; + // In PHP, an associative array like {"type":"string"} is still an array. + // We need to distinguish between a list of schemas (tuple validation) + // and a single schema object (list validation). A simple way is to + // check if the keys are sequential integers from 0. + $is_tuple_validation = is_array( $schema['items'] ) && array_keys( $schema['items'] ) === range( 0, count( $schema['items'] ) - 1 ); + + if ( $is_tuple_validation ) { + // === TUPLE VALIDATION === + // The validator doesn't support `additionalItems`, so we can report an error + // if the data array has more items than the schema tuple defines. + if ( isset( $schema['maxItems'] ) && count( $data ) > $schema['maxItems'] ) { + // This check is redundant if maxItems is always equal to count(items), but kept for robustness. + } elseif ( count( $data ) > count( $schema['items'] ) ) { + // This validator does not support `additionalItems`, so we treat extra items as an error. + $children_errors[] = new ValidationError( + $this->convert_path_to_string( $path ), + 'additional-items-not-allowed', + 'Tuple validation failed: expected ' . count( $schema['items'] ) . ' items but got ' . count( $data ) . '.', + array( + 'expectedCount' => count( $schema['items'] ), + 'actualCount' => count( $data ), + ) + ); + } + + foreach ( $data as $idx => $item ) { + if ( isset( $schema['items'][ $idx ] ) ) { + // This item has a specific schema defined in the tuple. + $error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'][ $idx ] ); + if ( $error ) { + $children_errors[] = $error; + } + } + // If there's no schema at $schema['items'][$idx], it's an "additional item", handled above. + } + } else { + // === LIST VALIDATION (existing logic) === + foreach ( $data as $idx => $item ) { + $error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'] ); + if ( $error ) { + $children_errors[] = $error; + } } } } From bb445dcc2e8a00745254997dc9ac4190b36ac8a8 Mon Sep 17 00:00:00 2001 From: ashfame Date: Mon, 8 Dec 2025 14:31:56 +0400 Subject: [PATCH 3/3] improve code readability, with increased verbosity + add more tests --- .../HumanFriendlySchemaValidatorTest.php | 22 ++++++++ .../class-humanfriendlyschemavalidator.php | 50 ++++++++++++------- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php b/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php index bb7f6716..e05914b3 100644 --- a/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php +++ b/components/Blueprints/Tests/Unit/Validator/HumanFriendlySchemaValidatorTest.php @@ -312,6 +312,28 @@ public static function arrayProvider(): array { false, 'Missing required field: id.', ], + 'tuple validation: valid with different schemas' => [ + [ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'integer' ] ] ], + [ 'a string', 123 ], + true, + ], + 'tuple validation: valid with identical schemas' => [ + [ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'string' ] ] ], + [ 'hello', 'world' ], + true, + ], + 'tuple validation: invalid item type' => [ + [ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'integer' ] ] ], + [ 'a string', 'another string' ], // Second item should be an integer + false, + 'Expected type "integer" but got type "string".', + ], + 'tuple validation: too many items' => [ + [ 'type' => 'array', 'items' => [ [ 'type' => 'string' ], [ 'type' => 'integer' ] ] ], + [ 'a string', 123, 'extra item' ], // Contains one too many items + false, + 'Tuple validation failed: expected at most 2 items but got 3.', + ], ]; } diff --git a/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php b/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php index ec940725..d758a965 100644 --- a/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php +++ b/components/Blueprints/Validator/class-humanfriendlyschemavalidator.php @@ -731,26 +731,34 @@ private function validate_object( array $path, $data, array $schema ): ?Validati private function validate_array( array $path, array $data, array $schema ): ?ValidationError { $children_errors = array(); if ( isset( $schema['items'] ) ) { - // In PHP, an associative array like {"type":"string"} is still an array. - // We need to distinguish between a list of schemas (tuple validation) - // and a single schema object (list validation). A simple way is to - // check if the keys are sequential integers from 0. - $is_tuple_validation = is_array( $schema['items'] ) && array_keys( $schema['items'] ) === range( 0, count( $schema['items'] ) - 1 ); - - if ( $is_tuple_validation ) { - // === TUPLE VALIDATION === - // The validator doesn't support `additionalItems`, so we can report an error - // if the data array has more items than the schema tuple defines. - if ( isset( $schema['maxItems'] ) && count( $data ) > $schema['maxItems'] ) { - // This check is redundant if maxItems is always equal to count(items), but kept for robustness. - } elseif ( count( $data ) > count( $schema['items'] ) ) { - // This validator does not support `additionalItems`, so we treat extra items as an error. + // JSON schema supports two types of array validation: + // + // 1. List validation: + // `{"type":"string"}` + // A single schema object that all items in the data array must conform to. + // In PHP, this is represented as an associative array, + // `["type"=>"string"]` + // + // 2. Tuple validation: + // `[{"type":"string"},{"type":"object"}]`, + // A list of schemas, where each item in the data array is validated + // against the schema at the same index. + // In PHP, this is a numerically indexed array. + // `[0=>['type'=>'string'],1=>['type'=>'object']]`. + $is_tuple_schema = is_array( $schema['items'] ) && array_keys( $schema['items'] ) === range( 0, count( $schema['items'] ) - 1 ); + + if ( $is_tuple_schema ) { + // Tuple validation. + // Note: We do not support the "additionalItems" keyword. + // Therefore, we treat it as an error if the data array contains more items + // than are defined in the schema's "items" tuple. + if ( count( $data ) > count( $schema['items'] ) ) { $children_errors[] = new ValidationError( $this->convert_path_to_string( $path ), 'additional-items-not-allowed', - 'Tuple validation failed: expected ' . count( $schema['items'] ) . ' items but got ' . count( $data ) . '.', + 'Tuple validation failed: expected at most ' . count( $schema['items'] ) . ' items but got ' . count( $data ) . '.', array( - 'expectedCount' => count( $schema['items'] ), + 'expectedMaxCount' => count( $schema['items'] ), 'actualCount' => count( $data ), ) ); @@ -758,16 +766,22 @@ private function validate_array( array $path, array $data, array $schema ): ?Val foreach ( $data as $idx => $item ) { if ( isset( $schema['items'][ $idx ] ) ) { + // Guard against a malformed schema where an item in the tuple is not a valid schema object. + if ( ! is_array( $schema['items'][ $idx ] ) ) { + throw new UnsupportedSchemaException( 'Invalid tuple schema: items must be schema objects, but a non-object was found at index ' . $idx ); + } // This item has a specific schema defined in the tuple. $error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'][ $idx ] ); if ( $error ) { $children_errors[] = $error; } } - // If there's no schema at $schema['items'][$idx], it's an "additional item", handled above. + // If there's no schema at $schema['items'][$idx], + // the data item is an "additional item", + // handled by the count check above. } } else { - // === LIST VALIDATION (existing logic) === + // List validation. foreach ( $data as $idx => $item ) { $error = $this->validate_node( array_merge( $path, array( $idx ) ), $item, $schema['items'] ); if ( $error ) {