diff --git a/.changeset/thirty-knives-know.md b/.changeset/thirty-knives-know.md new file mode 100644 index 00000000..7dab8aff --- /dev/null +++ b/.changeset/thirty-knives-know.md @@ -0,0 +1,144 @@ +--- +"@wpengine/wp-graphql-content-blocks": minor +--- + +# Querying Object-Type Block Attributes in WPGraphQL + +## Overview +With this update, you can now query object-type block attributes with each property individually, provided that the **typed structure** is defined in the class `typed_object_attributes` property or through a **WordPress filter**. + +## How It Works +The `typed_object_attributes` is a **filterable** array that defines the expected **typed structure** for object-type block attributes. + +- The **keys** in `typed_object_attributes` correspond to **object attribute names** in the block. +- Each value is an **associative array**, where: + - The key represents the **property name** inside the object. + - The value defines the **WPGraphQL type** (e.g., `string`, `integer`, `object`, etc.). +- If a block attribute has a specified **typed structure**, only the properties listed within it will be processed. + +## Defining Typed Object Attributes +Typed object attributes can be **defined in two ways**: + +### 1. In a Child Class (`typed_object_attributes` property) +Developers can extend the `Block` class and specify **typed properties** directly: + +```php +class CustomMovieBlock extends Block { + /** + * {@inheritDoc} + * + * @var array> + */ + protected array $typed_object_attributes = [ + 'film' => [ + 'id' => 'integer', + 'title' => 'string', + 'director' => 'string', + 'soundtrack' => 'object', + ], + 'soundtrack' => [ + 'title' => 'string', + 'artist' => 'string' + ], + ]; +} +``` + +### 2. Via WordPress Filter +You can also define **typed structures dynamically** using a WordPress filter. + +```php +add_filter( + 'wpgraphql_content_blocks_object_typing_my-custom-plugin_movie-block', + function () { + return [ + 'film' => [ + 'id' => 'integer', + 'title' => 'string', + 'director' => 'string', + 'soundtrack' => 'object', + ], + 'soundtrack' => [ + 'title' => 'string', + 'artist' => 'string' + ], + ]; + } +); +``` + +## Filter Naming Convention +To apply custom typing via a filter, use the following format: + +``` +wpgraphql_content_blocks_object_typing_{block-name} +``` +- Replace `/` in the block name with `-`. +- Example: + - **Block name**: `my-custom-plugin/movie-block` + - **Filter name**: `wpgraphql_content_blocks_object_typing_my-custom-plugin_movie-block` + +## Example: + + +### Example `block.json` Definition +If the block has attributes defined as **objects**, like this: + +```json +"attributes": { + "film": { + "type": "object", + "default": { + "id": 1, + "title": "The Matrix", + "director": "Director Name" + } + }, + "soundtrack": { + "type": "object", + "default": { + "title": "The Matrix Revolutions...", + "artist": "Artist Name" + } + } +} +``` +This means: +- The `film` attribute contains `id`, `title`, `director`. +- The `soundtrack` attribute contains `title` and `artist`. + +## WPGraphQL Query Example +Once the typed object attributes are **defined**, you can query them **individually** in WPGraphQL. + +```graphql +fragment Movie on MyCustomPluginMovieBlock { + attributes { + film { + id + title + director + soundtrack { + title + } + } + soundtrack { + title + artist + } + } +} + +query GetAllPostsWhichSupportBlockEditor { + posts { + edges { + node { + editorBlocks { + __typename + name + ...Movie + } + } + } + } +} +``` diff --git a/README.md b/README.md index 55235280..c5e734d9 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,150 @@ So in order to put everything back in the Headless site, you want to use the `fl > Currently the `clientId` field is only unique per request and is not persisted anywhere. If you perform another request each block will be assigned a new `clientId` each time. +--- + +## Querying Object-Type Block Attributes in WPGraphQL + +### Overview +With this update, you can now query object-type block attributes with each property individually, provided that the **typed structure** is defined in the class `typed_object_attributes` property or through a **WordPress filter**. + +### How It Works +The `typed_object_attributes` is a **filterable** array that defines the expected **typed structure** for object-type block attributes. + +- The **keys** in `typed_object_attributes` correspond to **object attribute names** in the block. +- Each value is an **associative array**, where: + - The key represents the **property name** inside the object. + - The value defines the **WPGraphQL type** (e.g., `string`, `integer`, `object`, etc.). +- If a block attribute has a specified **typed structure**, only the properties listed within it will be processed. + +### Defining Typed Object Attributes +Typed object attributes can be **defined in two ways**: + +#### 1. In a Child Class (`typed_object_attributes` property) +Developers can extend the `Block` class and specify **typed properties** directly: + +```php +class CustomMovieBlock extends Block { + /** + * {@inheritDoc} + * + * @var array> + */ + protected array $typed_object_attributes = [ + 'film' => [ + 'id' => 'integer', + 'title' => 'string', + 'director' => 'string', + 'soundtrack' => 'object', + ], + 'soundtrack' => [ + 'title' => 'string', + 'artist' => 'string' + ], + ]; +} +``` + +#### 2. Via WordPress Filter +You can also define **typed structures dynamically** using a WordPress filter. + +```php +add_filter( + 'wpgraphql_content_blocks_object_typing_my-custom-plugin_movie-block', + function () { + return [ + 'film' => [ + 'id' => 'integer', + 'title' => 'string', + 'director' => 'string', + 'soundtrack' => 'object', + ], + 'soundtrack' => [ + 'title' => 'string', + 'artist' => 'string' + ], + ]; + } +); +``` + +### Filter Naming Convention +To apply custom typing via a filter, use the following format: + +``` +wpgraphql_content_blocks_object_typing_{block-name} +``` +- Replace `/` in the block name with `-`. +- Example: + - **Block name**: `my-custom-plugin/movie-block` + - **Filter name**: `wpgraphql_content_blocks_object_typing_my-custom-plugin_movie-block` + +### Example: + + +#### Example `block.json` Definition +If the block has attributes defined as **objects**, like this: + +```json +"attributes": { + "film": { + "type": "object", + "default": { + "id": 1, + "title": "The Matrix", + "director": "Director Name" + } + }, + "soundtrack": { + "type": "object", + "default": { + "title": "The Matrix Revolutions...", + "artist": "Artist Name" + } + } +} +``` +This means: +- The `film` attribute contains `id`, `title`, `director`. +- The `soundtrack` attribute contains `title` and `artist`. + +### WPGraphQL Query Example +Once the typed object attributes are **defined**, you can query them **individually** in WPGraphQL. + +```graphql +fragment Movie on MyCustomPluginMovieBlock { + attributes { + film { + id + title + director + soundtrack { + title + } + } + soundtrack { + title + artist + } + } +} + +query GetAllPostsWhichSupportBlockEditor { + posts { + edges { + node { + editorBlocks { + __typename + name + ...Movie + } + } + } + } +} +``` +--- + ### Contributor License Agreement All external contributors to WP Engine products must have a signed Contributor License Agreement (CLA) in place before the contribution may be accepted into any WP Engine codebase. diff --git a/includes/Blocks/Block.php b/includes/Blocks/Block.php index 94258282..fb95ef92 100644 --- a/includes/Blocks/Block.php +++ b/includes/Blocks/Block.php @@ -53,6 +53,14 @@ class Block { */ protected ?array $additional_block_attributes; + /** + * A filterable array of block object attributes that are typed. + * The keys could be the object attribute names of the block and the value is an associative array where the key is the property name and the value is the type. + * + * @var array> + */ + protected array $typed_object_attributes = []; + /** * Block constructor. * @@ -64,9 +72,24 @@ public function __construct( WP_Block_Type $block, Registry $block_registry ) { $this->block_registry = $block_registry; $this->block_attributes = $this->block->attributes; $this->type_name = WPGraphQLHelpers::format_type_name( $block->name ); + + $this->filter_typed_object_attributes(); $this->register_block_type(); } + /** + * Filters the typed object attributes for the block. + * + * @return void + */ + private function filter_typed_object_attributes() { + $block_name = str_replace( [ '/' ], '_', $this->block->name ); + + if ( has_filter( 'wpgraphql_content_blocks_object_typing_' . $block_name ) ) { + $this->typed_object_attributes = (array) apply_filters( 'wpgraphql_content_blocks_object_typing_' . $block_name, [] ); + } + } + /** * Registers the Block Type to WPGraphQL. * @@ -126,7 +149,7 @@ private function register_block_attributes_as_fields(): void { } /** - * Returns the type of the block attribute + * Returns the type of the block attribute. * * @param string $name The block name * @param array $attribute The block attribute config @@ -135,57 +158,87 @@ private function register_block_attributes_as_fields(): void { * @return mixed */ private function get_attribute_type( $name, $attribute, $prefix ) { - $type = null; - - if ( isset( $attribute['type'] ) ) { - switch ( $attribute['type'] ) { - case 'rich-text': - case 'string': - $type = 'String'; - break; - case 'boolean': - $type = 'Boolean'; - break; - case 'number': - $type = 'Float'; - break; - case 'integer': - $type = 'Int'; - break; - case 'array': - if ( isset( $attribute['query'] ) ) { - $type = [ 'list_of' => $this->get_query_type( $name, $attribute['query'], $prefix ) ]; - } elseif ( isset( $attribute['items'] ) ) { - $of_type = $this->get_attribute_type( $name, $attribute['items'], $prefix ); - - if ( null !== $of_type ) { - $type = [ 'list_of' => $of_type ]; - } else { - $type = Scalar::get_block_attributes_array_type_name(); - } - } else { - $type = Scalar::get_block_attributes_array_type_name(); - } - break; - case 'object': - $type = Scalar::get_block_attributes_object_type_name(); - break; - } - } elseif ( isset( $attribute['source'] ) ) { - $type = 'String'; - } + $type = null; + $attribute_type = $attribute['type'] ?? null; - if ( null !== $type ) { - $default_value = $attribute['default'] ?? null; + switch ( $attribute_type ) { + case 'rich-text': + case 'string': + $type = 'String'; + break; + case 'boolean': + $type = 'Boolean'; + break; + case 'number': + $type = 'Float'; + break; + case 'integer': + $type = 'Int'; + break; + case 'array': + $type = $this->process_array_attributes( $name, $attribute, $prefix ); + break; + case 'object': + $type = $this->process_object_attributes( $name, $attribute, $prefix ); + break; + case null: + // Default to String if only 'source' is defined, otherwise return null. + $type = isset( $attribute['source'] ) ? 'String' : null; + break; + } - if ( isset( $default_value ) ) { - $type = [ 'non_null' => $type ]; - } + if ( null !== $type && isset( $attribute['default'] ) ) { + $type = [ 'non_null' => $type ]; } return $type; } + /** + * Processes the array attributes as query, list of items or scalar array. + * + * @param string $name The block name + * @param array $attribute The block attribute config + * @param string $prefix Current prefix string to use for the get_query_type + * + * @return array|string + */ + private function process_array_attributes( $name, $attribute, $prefix ) { + if ( isset( $attribute['query'] ) ) { + return [ 'list_of' => $this->register_inner_object_type( $name, $attribute['query'], $prefix ) ]; + } + + $of_type = null; + if ( isset( $attribute['items'] ) ) { + $of_type = $this->get_attribute_type( $name, $attribute['items'], $prefix ); + } + + return null !== $of_type ? [ 'list_of' => $of_type ] : Scalar::get_block_attributes_array_type_name(); + } + + /** + * Processes the object attributes as typed object if defined within filter, otherwise as scalar. + * + * @param string $name The block name + * @param array $attribute The block attribute config + * @param string $prefix Current prefix string to use for the get_query_type + * + * @return string + */ + private function process_object_attributes( $name, $attribute, $prefix ) { + // If there is no typing for this object attribute, return the default scalar type. + if ( empty( $this->typed_object_attributes[ $name ] ) ) { + return Scalar::get_block_attributes_object_type_name(); + } + + $typed = $this->build_typed_object_config( + $attribute['default'] ?? [], + $this->typed_object_attributes[ $name ] + ); + + return $typed ? $this->register_inner_object_type( $name, $typed, $prefix ) : Scalar::get_block_attributes_object_type_name(); + } + /** * Gets the WPGraphQL field registration config for the block attributes. * @@ -230,16 +283,15 @@ private function get_block_attribute_fields( ?array $block_attributes, string $p } /** - * Returns the type of the block query attribute + * Dynamically creates and registers a GraphQL object type for queries or typed object attributes. * - * @param string $name The block name - * @param array $query The block query config - * @param string $prefix The current prefix string to use for registering the new query attribute type + * @param string $name The block name. + * @param array $config The block config. + * @param string $prefix The current prefix string to use for registering the new attribute type. */ - private function get_query_type( string $name, array $query, string $prefix ): string { - $type = $prefix . ucfirst( $name ); - - $fields = $this->create_attributes_fields( $query, $type ); + private function register_inner_object_type( string $name, array $config, string $prefix ): string { + $type = $prefix . ucfirst( $name ); + $fields = $this->create_attributes_fields( $config, $type ); register_graphql_object_type( $type, @@ -257,6 +309,26 @@ private function get_query_type( string $name, array $query, string $prefix ): s return $type; } + /** + * Generates typed object-type attribute config by merging the default values with the typed object configuration. + * When typing is specified for an attribute, only the typed properties are returned. + * + * @param array $default_values Default record of the attribute. + * @param array $typed Typed object configuration. + */ + private function build_typed_object_config( $default_values, $typed ): array { + return array_combine( + array_keys( $typed ), + array_map( + static fn ( $key ) => [ + 'type' => $typed[ $key ], + 'default' => $default_values[ $key ] ?? null, + ], + array_keys( $typed ) + ) + ) ?: []; + } + /** * Creates the new attribute fields for query types * diff --git a/tests/unit/BlockTest.php b/tests/unit/BlockTest.php new file mode 100644 index 00000000..08493b1b --- /dev/null +++ b/tests/unit/BlockTest.php @@ -0,0 +1,233 @@ +name = 'test-block'; // Prevent null argument issue + $blockMock->attributes = []; + + // Retrieve real instances required for Registry. + $typeRegistry = WPGraphQL::get_type_registry(); + $blockTypeRegistry = WP_Block_Type_Registry::get_instance(); + + // Create an instance of Registry with dependencies. + $registry = new Registry($typeRegistry, $blockTypeRegistry); + + // Create an instance of Block with the real dependencies. + $this->block = new Block($blockMock, $registry); + } + + /** + * Access private method get_attribute_type using reflection. + */ + private function invokeGetAttributeType($name, $attribute, $prefix) { + $method = new \ReflectionMethod($this->block, 'get_attribute_type'); + $method->setAccessible(true); + return $method->invoke($this->block, $name, $attribute, $prefix); + } + + public function testStringType() { + $attribute = ['type' => 'string']; + $this->assertEquals('String', $this->invokeGetAttributeType('block_name', $attribute, 'prefix')); + } + + public function testBooleanType() { + $attribute = ['type' => 'boolean']; + $this->assertEquals('Boolean', $this->invokeGetAttributeType('block_name', $attribute, 'prefix')); + } + + public function testFloatType() { + $attribute = ['type' => 'number']; + $this->assertEquals('Float', $this->invokeGetAttributeType('block_name', $attribute, 'prefix')); + } + + public function testIntType() { + $attribute = ['type' => 'integer']; + $this->assertEquals('Int', $this->invokeGetAttributeType('block_name', $attribute, 'prefix')); + } + + public function testArrayTypeWithoutQuery() { + $expectedArrayType = Scalar::get_block_attributes_array_type_name(); + + $attribute = ['type' => 'array']; + $this->assertEquals( + $expectedArrayType, + $this->invokeGetAttributeType('block_name', $attribute, 'prefix') + ); + } + + public function testArrayTypeWithQuery() { + $attribute = [ + 'type' => 'array', + 'query' => [ + 'key' => [ + 'type' => 'string', + ], + ], + ]; + + $result = $this->invokeGetAttributeType('block_name', $attribute, 'prefix'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('list_of', $result); + $this->assertIsString($result['list_of']); // Expecting a generated GraphQL type name + } + + + public function testObjectType() { + // Call the real method instead of mocking + $expectedObjectType = Scalar::get_block_attributes_object_type_name(); + + $this->assertEquals( + $expectedObjectType, + $this->invokeGetAttributeType('block_name', ['type' => 'object'], 'prefix') + ); + } + + public function testWithDefaultValue() { + $attribute = ['type' => 'string', 'default' => 'test_value']; + $this->assertEquals(['non_null' => 'String'], $this->invokeGetAttributeType('block_name', $attribute, 'prefix')); + } + + public function testArrayTypeWithItems() { + $attribute = [ + 'type' => 'array', + 'items' => ['type' => 'integer'] + ]; + + $result = $this->invokeGetAttributeType('block_name', $attribute, 'prefix'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('list_of', $result); + $this->assertEquals('Int', $result['list_of']); // Should be a list of integers + } + + public function testAttributeWithSourceKey() { + $attribute = ['source' => 'meta']; + + $this->assertEquals('String', $this->invokeGetAttributeType('block_name', $attribute, 'prefix')); + } + + public function testUnknownOrMissingType() { + $attribute = []; // No 'type' key + + $this->assertNull($this->invokeGetAttributeType('block_name', $attribute, 'prefix')); + } + + public function testDefaultValuesForAllTypes() { + $cases = [ + ['type' => 'string', 'default' => 'text', 'expected' => ['non_null' => 'String']], + ['type' => 'boolean', 'default' => true, 'expected' => ['non_null' => 'Boolean']], + ['type' => 'number', 'default' => 3.14, 'expected' => ['non_null' => 'Float']], + ['type' => 'integer', 'default' => 42, 'expected' => ['non_null' => 'Int']], + ['type' => 'array', 'default' => [1, 2, 3], 'expected' => ['non_null' => Scalar::get_block_attributes_array_type_name()]], + ]; + + foreach ($cases as $case) { + $this->assertEquals($case['expected'], $this->invokeGetAttributeType('block_name', $case, 'prefix')); + } + } + + /** + * Access private method process_object_attributes using reflection. + */ + private function invokeProcessObjectAttributes($name, $attribute, $prefix) { + $methodFilter = new \ReflectionMethod($this->block, 'filter_typed_object_attributes'); + $methodFilter->setAccessible(true); + $methodFilter->invoke($this->block); // Run the method to populate the property + + $property = new \ReflectionProperty($this->block, 'typed_object_attributes'); + $property->setAccessible(true); + + $method = new \ReflectionMethod($this->block, 'process_object_attributes'); + $method->setAccessible(true); + + return $method->invoke($this->block, $name, $attribute, $prefix); + } + + /** Test default behavior when no filter or child class is used */ + public function testProcessObjectAttributes_Default() { + $attribute = ['type' => 'object']; + $expected = Scalar::get_block_attributes_object_type_name(); + + $this->assertEquals( + $expected, + $this->invokeProcessObjectAttributes('block_name', $attribute, 'prefix') + ); + } + + /** Test dynamically defined object type properties via WordPress filter */ + public function testProcessObjectAttributes_WithFilter() { + add_filter('wpgraphql_content_blocks_object_typing_test-block', function($attributes) { + $attributes['film'] = [ + 'director' => 'string', + 'title' => 'string', + ]; + return $attributes; + }); + + $attribute = [ + 'type' => 'object', + 'default' => ['director' => '', 'title' => ''], + ]; + + $result = $this->invokeProcessObjectAttributes('film', $attribute, 'registeredObjectPrefix'); + $this->assertIsString($result); + $this->assertStringContainsString('registeredObjectPrefix', $result); + } + + /** Test extending `Block` in a child class and specifying a custom multidimensional array */ + public function testProcessObjectAttributes_ExtendedClass() { + // Create a real WP_Block_Type instance + $blockMock = new WP_Block_Type('test-block'); + $blockMock->attributes = []; + + // Get real instances of dependencies + $typeRegistry = WPGraphQL::get_type_registry(); + $blockTypeRegistry = WP_Block_Type_Registry::get_instance(); + $registry = new Registry($typeRegistry, $blockTypeRegistry); + + // Use real Registry instance instead of mocking + $childBlock = new class($blockMock, $registry) extends Block { + protected array $typed_object_attributes = [ + 'customObject' => [ + 'propertyA' => 'string', + 'propertyB' => 'boolean', + ] + ]; + }; + + $attribute = [ + 'type' => 'object', + 'default' => ['propertyA' => '', 'propertyB' => false], + ]; + + $method = new \ReflectionMethod($childBlock, 'process_object_attributes'); + $method->setAccessible(true); + $result = $method->invoke($childBlock, 'customObject', $attribute, 'registeredObjectPrefix'); + + $this->assertIsString($result); + $this->assertStringContainsString('registeredObjectPrefix', $result); + } + + public function tearDown(): void { + remove_all_filters('wpgraphql_content_blocks_object_typing_test-block'); + Mockery::close(); + parent::tearDown(); + } +}