diff --git a/composer.json b/composer.json index ee8ebf2a5..8c1ac976f 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "require": {}, "require-dev": { "behat/behat": "~2.5", - "wp-cli/wp-cli": "*" + "wp-cli/wp-cli": "*", + "phpunit/phpunit": "^4.8" }, "extra": { "branch-alias": { diff --git a/features/post-meta.feature b/features/post-meta.feature index e8dd92da3..45398c3a5 100644 --- a/features/post-meta.feature +++ b/features/post-meta.feature @@ -139,3 +139,273 @@ Feature: Manage post custom fields """ My\New\Meta """ + + @pluck + Scenario: Nested values can be retrieved. + Given a WP install + And an input.json file: + """ + { + "foo": "bar" + } + """ + And I run `wp post meta set 1 meta-key --format=json < input.json` + + When I run `wp post meta pluck 1 meta-key foo` + Then STDOUT should be: + """ + bar + """ + + @pluck @pluck-deep + Scenario: A nested value can be retrieved at any depth. + Given a WP install + And an input.json file: + """ + { + "foo": { + "bar": { + "baz": "some value" + } + }, + "foo.com": { + "visitors": 999 + } + } + """ + And I run `wp post meta set 1 meta-key --format=json < input.json` + + When I run `wp post meta pluck 1 meta-key foo bar baz` + Then STDOUT should be: + """ + some value + """ + + When I run `wp post meta pluck 1 meta-key foo.com visitors` + Then STDOUT should be: + """ + 999 + """ + + @pluck @pluck-fail + Scenario: Attempting to pluck a non-existent nested value fails. + Given a WP install + And I run `wp post meta set 1 meta-key '{ "key": "value" }' --format=json` + + When I run `wp post meta pluck 1 meta-key key` + Then STDOUT should be: + """ + value + """ + + When I try `wp post meta pluck 1 meta-key foo` + Then STDOUT should be empty + And the return code should be 1 + + @pluck @pluck-fail + Scenario: Attempting to pluck from a primitive value fails. + Given a WP install + And I run `wp post meta set 1 meta-key simple-value` + + When I try `wp post meta pluck 1 meta-key foo` + Then STDOUT should be empty + And the return code should be 1 + + @pluck @pluck-numeric + Scenario: A nested value can be retrieved from an integer key. + Given a WP install + And I run `wp post meta set 1 meta-key '[ "foo", "bar" ]' --format=json` + + When I run `wp post meta pluck 1 meta-key 0` + Then STDOUT should be: + """ + foo + """ + + @patch @patch-update @patch-arg + Scenario: Nested values can be changed. + Given a WP install + And an input.json file: + """ + { + "foo": "bar" + } + """ + And I run `wp post meta set 1 meta-key --format=json < input.json` + + When I run `wp post meta patch update 1 meta-key foo baz` + Then STDOUT should be: + """ + Success: Updated custom field 'meta-key'. + """ + + When I run `wp post meta get 1 meta-key --format=json` + Then STDOUT should be JSON containing: + """ + { + "foo": "baz" + } + """ + + @patch @patch-update @patch-stdin + Scenario: Nested values can be set with a value from STDIN. + Given a WP install + And an input.json file: + """ + { + "foo": { + "bar": "baz" + }, + "bar": "bad" + } + """ + And a patch file: + """ + new value + """ + And I run `wp post meta set 1 meta-key --format=json < input.json` + + When I run `wp post meta patch update 1 meta-key foo bar < patch` + Then STDOUT should be: + """ + Success: Updated custom field 'meta-key'. + """ + + When I run `wp post meta get 1 meta-key --format=json` + Then STDOUT should be JSON containing: + """ + { + "foo": { + "bar": "new value" + }, + "bar": "bad" + } + """ + + @patch @patch-update @patch-fail + Scenario: Attempting to update a nested value fails if a parent's key does not exist. + Given a WP install + And an input.json file: + """ + { + "foo": { + "bar": "baz" + }, + "bar": "bad" + } + """ + And I run `wp post meta set 1 meta-key --format=json < input.json` + + When I try `wp post meta patch update 1 meta-key foo not-a-key new-value` + Then STDOUT should be empty + And STDERR should contain: + """ + No data exists for key "not-a-key" + """ + And the return code should be 1 + + @patch @patch-delete + Scenario: A key can be deleted from a nested value. + Given a WP install + And an input.json file: + """ + { + "foo": { + "bar": "baz", + "abe": "lincoln" + } + } + """ + And I run `wp post meta set 1 meta-key --format=json < input.json` + + When I run `wp post meta patch delete 1 meta-key foo bar` + Then STDOUT should be: + """ + Success: Updated custom field 'meta-key'. + """ + + When I run `wp post meta get 1 meta-key --format=json` + Then STDOUT should be JSON containing: + """ + { + "foo": { + "abe": "lincoln" + } + } + """ + + @patch @patch-fail @patch-delete @patch-delete-fail + Scenario: A key cannot be deleted from a nested value from a non-existent key. + Given a WP install + And an input.json file: + """ + { + "foo": { + "bar": "baz" + } + } + """ + And I run `wp post meta set 1 meta-key --format=json < input.json` + + When I try `wp post meta patch delete 1 meta-key foo not-a-key` + Then STDOUT should be empty + And STDERR should contain: + """ + No data exists for key "not-a-key" + """ + And the return code should be 1 + + @patch @patch-insert + Scenario: A new key can be inserted into a nested value. + Given a WP install + And I run `wp post meta set 1 meta-key '{}' --format=json` + + When I run `wp post meta patch insert 1 meta-key foo bar` + Then STDOUT should be: + """ + Success: Updated custom field 'meta-key'. + """ + + When I run `wp post meta get 1 meta-key --format=json` + Then STDOUT should be JSON containing: + """ + { + "foo": "bar" + } + """ + + @patch @patch-fail @patch-insert @patch-insert-fail + Scenario: A new key cannot be inserted into a non-nested value. + Given a WP install + And I run `wp post meta set 1 meta-key 'a simple value'` + + When I try `wp post meta patch insert 1 meta-key foo bar` + Then STDOUT should be empty + And STDERR should contain: + """ + Cannot create key "foo" + """ + And the return code should be 1 + + When I run `wp post meta get 1 meta-key` + Then STDOUT should be: + """ + a simple value + """ + + @patch @patch-numeric + Scenario: A nested value can be updated using an integer key. + Given a WP install + And I run `wp post meta set 1 meta-key '[ "foo", "bar" ]' --format=json` + + When I run `wp post meta patch update 1 meta-key 0 new` + Then STDOUT should be: + """ + Success: Updated custom field 'meta-key'. + """ + + When I run `wp post meta get 1 meta-key --format=json` + Then STDOUT should be JSON containing: + """ + [ "new", "bar" ] + """ \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 000000000..16cff729f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + tests + + + + + src + + + diff --git a/src/WP_CLI/CommandWithMeta.php b/src/WP_CLI/CommandWithMeta.php index d1224eaf9..d1092c884 100644 --- a/src/WP_CLI/CommandWithMeta.php +++ b/src/WP_CLI/CommandWithMeta.php @@ -3,6 +3,7 @@ namespace WP_CLI; use WP_CLI; +use WP_CLI\Entity\RecursiveDataStructureTraverser; /** * Base class for WP-CLI commands that deal with metadata @@ -253,6 +254,134 @@ public function update( $args, $assoc_args ) { } + /** + * Get a nested value from a meta field. + * + * ## OPTIONS + * + * + * : The ID of the object. + * + * + * : The name of the meta field to get. + * + * ... + * : The name(s) of the keys within the value to locate the value to pluck. + * + * [--format=] + * : The output format of the value. + * --- + * default: plaintext + * options: + * - plaintext + * - json + * - yaml + */ + public function pluck( $args, $assoc_args ) { + list( $object_id, $meta_key ) = $args; + $object_id = $this->check_object_id( $object_id ); + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; + } + return $key; + }, array_slice( $args, 2 ) ); + + $value = get_metadata( $this->meta_type, $object_id, $meta_key, true ); + + $traverser = new RecursiveDataStructureTraverser( $value ); + + try { + $value = $traverser->get( $key_path ); + } catch ( \Exception $e ) { + die( 1 ); + } + + WP_CLI::print_value( $value, $assoc_args ); + } + + /** + * Update a nested value for a meta field. + * + * ## OPTIONS + * + * + * : Patch action to perform. + * --- + * options: + * - insert + * - update + * - delete + * --- + * + * + * : The ID of the object. + * + * + * : The name of the meta field to update. + * + * ... + * : The name(s) of the keys within the value to locate the value to patch. + * + * [] + * : The new value. If omitted, the value is read from STDIN. + * + * [--format=] + * : The serialization format for the value. + * --- + * default: plaintext + * options: + * - plaintext + * - json + * --- + */ + public function patch( $args, $assoc_args ) { + list( $action, $object_id, $meta_key ) = $args; + $object_id = $this->check_object_id( $object_id ); + $key_path = array_map( function( $key ) { + if ( is_numeric( $key ) && ( $key === (string) intval( $key ) ) ) { + return (int) $key; + } + return $key; + }, array_slice( $args, 3 ) ); + + if ( 'delete' == $action ) { + $patch_value = null; + } elseif ( Entity\Utils::has_stdin() ) { + $stdin_value = WP_CLI::get_value_from_arg_or_stdin( $args, -1 ); + $patch_value = WP_CLI::read_value( trim( $stdin_value ), $assoc_args ); + } else { + // Take the patch value as the last positional argument. Mutates $key_path to be 1 element shorter! + $patch_value = WP_CLI::read_value( array_pop( $key_path ), $assoc_args ); + } + + /* Need to make a copy of $current_meta_value here as it is modified by reference */ + $current_meta_value = $old_meta_value = sanitize_meta( $meta_key, get_metadata( $this->meta_type, $object_id, $meta_key, true ), $this->meta_type ); + + $traverser = new RecursiveDataStructureTraverser( $current_meta_value ); + + try { + $traverser->$action( $key_path, $patch_value ); + } catch ( \Exception $e ) { + WP_CLI::error( $e->getMessage() ); + } + + $patched_meta_value = sanitize_meta( $meta_key, $traverser->value(), $this->meta_type ); + + if ( $patched_meta_value === $old_meta_value ) { + WP_CLI::success( "Value passed for custom field '$meta_key' is unchanged." ); + } else { + $slashed = wp_slash( $patched_meta_value ); + $success = update_metadata( $this->meta_type, $object_id, $meta_key, $slashed ); + + if ( $success ) { + WP_CLI::success( "Updated custom field '$meta_key'." ); + } else { + WP_CLI::error( "Failed to update custom field '$meta_key'." ); + } + } + } + /** * Get the fields for this object's meta * diff --git a/src/WP_CLI/Entity/NonExistentKeyException.php b/src/WP_CLI/Entity/NonExistentKeyException.php new file mode 100644 index 000000000..be007d195 --- /dev/null +++ b/src/WP_CLI/Entity/NonExistentKeyException.php @@ -0,0 +1,22 @@ +traverser = $traverser; + } + + /** + * @return \WP_CLI\Entity\RecursiveDataStructureTraverser + */ + public function get_traverser() { + return $this->traverser; + } +} diff --git a/src/WP_CLI/Entity/RecursiveDataStructureTraverser.php b/src/WP_CLI/Entity/RecursiveDataStructureTraverser.php new file mode 100644 index 000000000..a7b7c93f6 --- /dev/null +++ b/src/WP_CLI/Entity/RecursiveDataStructureTraverser.php @@ -0,0 +1,177 @@ +data =& $data; + $this->key = $key; + $this->parent = $parent; + } + + /** + * Get the nested value at the given key path. + * + * @param string|int|array $key_path + * + * @return static + */ + public function get( $key_path ) { + return $this->traverse_to( (array) $key_path )->value(); + } + + /** + * Get the current data. + * + * @return mixed + */ + public function value() { + return $this->data; + } + + /** + * Update a nested value at the given key path. + * + * @param string|int|array $key_path + * @param mixed $value + */ + public function update( $key_path, $value ) { + $this->traverse_to( (array) $key_path )->set_value( $value ); + } + + /** + * Update the current data with the given value. + * + * This will mutate the variable which was passed into the constructor + * as the data is set and traversed by reference. + * + * @param mixed $value + */ + public function set_value( $value ) { + $this->data = $value; + } + + /** + * Unset the value at the given key path. + * + * @param $key_path + */ + public function delete( $key_path ) { + $this->traverse_to( (array) $key_path )->unset_on_parent(); + } + + /** + * Define a nested value while creating keys if they do not exist. + * + * @param array $key_path + * @param mixed $value + */ + public function insert( $key_path, $value ) { + try { + $this->update( $key_path, $value ); + } catch ( NonExistentKeyException $e ) { + $e->get_traverser()->create_key(); + $this->insert( $key_path, $value ); + } + } + + /** + * Delete the key on the parent's data that references this data. + */ + public function unset_on_parent() { + $this->parent->delete_by_key( $this->key ); + } + + /** + * Delete the given key from the data. + * + * @param $key + */ + public function delete_by_key( $key ) { + if ( is_array( $this->data ) ) { + unset( $this->data[ $key ] ); + } else { + unset( $this->data->$key ); + } + } + + /** + * Get an instance of the traverser for the given hierarchical key. + * + * @param array $key_path Hierarchical key path within the current data to traverse to. + * + * @throws NonExistentKeyException + * + * @return static + */ + public function traverse_to( array $key_path ) { + $current = array_shift( $key_path ); + + if ( null === $current ) { + return $this; + } + + if ( ! $this->exists( $current ) ) { + $exception = new NonExistentKeyException( "No data exists for key \"$current\"" ); + $exception->set_traverser( new static( $this->data, $current, $this->parent ) ); + throw $exception; + } + + foreach ( $this->data as $key => &$key_data ) { + if ( $key === $current ) { + $traverser = new static( $key_data, $key, $this ); + return $traverser->traverse_to( $key_path ); + } + } + } + + /** + * Create the key on the current data. + * + * @throws \UnexpectedValueException + */ + protected function create_key() { + if ( is_array( $this->data ) ) { + $this->data[ $this->key ] = null; + } elseif ( is_object( $this->data ) ) { + $this->data->{$this->key} = null; + } else { + throw new \UnexpectedValueException( sprintf( 'Cannot create key "%s" on data type %s', $this->key, gettype( $this->data ) ) ); + } + } + + /** + * Check if the given key exists on the current data. + * + * @param string $key + * + * @return bool + */ + public function exists( $key ) { + return ( is_array( $this->data ) && array_key_exists( $key, $this->data ) ) || + ( is_object( $this->data ) && property_exists( $this->data, $key ) ); + } +} diff --git a/src/WP_CLI/Entity/Utils.php b/src/WP_CLI/Entity/Utils.php new file mode 100644 index 000000000..7c2d92d6e --- /dev/null +++ b/src/WP_CLI/Entity/Utils.php @@ -0,0 +1,22 @@ + 'bar', + ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + + $this->assertEquals( 'bar', $traverser->get( 'foo' ) ); + } + + /** @test */ + function it_can_get_a_top_level_object_value() { + $object = (object) array( + 'foo' => 'bar', + ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + + $this->assertEquals( 'bar', $traverser->get( 'foo' ) ); + } + + /** @test */ + function it_can_get_a_nested_array_value() { + $array = array( + 'foo' => array( + 'bar' => array( + 'baz' => 'value' + ), + ), + ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + + $this->assertEquals( 'value', $traverser->get( array( 'foo', 'bar', 'baz' ) ) ); + } + + /** @test */ + function it_can_get_a_nested_object_value() { + $object = (object) array( + 'foo' => (object) array( + 'bar' => 'baz', + ), + ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + + $this->assertEquals( 'baz', $traverser->get( array( 'foo', 'bar' ) ) ); + } + + /** @test */ + function it_can_set_a_nested_array_value() { + $array = array( + 'foo' => array( + 'bar' => 'baz', + ), + ); + $this->assertEquals( 'baz', $array['foo']['bar'] ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + $traverser->update( array( 'foo', 'bar' ), 'new' ); + + $this->assertEquals( 'new', $array['foo']['bar'] ); + } + + /** @test */ + function it_can_set_a_nested_object_value() { + $object = (object) array( + 'foo' => (object) array( + 'bar' => 'baz', + ), + ); + $this->assertEquals( 'baz', $object->foo->bar ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + $traverser->update( array( 'foo', 'bar' ), 'new' ); + + $this->assertEquals( 'new', $object->foo->bar ); + } + + /** @test */ + function it_can_delete_a_nested_array_value() { + $array = array( + 'foo' => array( + 'bar' => 'baz', + ), + ); + $this->assertArrayHasKey( 'bar', $array['foo'] ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + $traverser->delete( array( 'foo', 'bar' ) ); + + $this->assertArrayNotHasKey( 'bar', $array['foo'] ); + } + + /** @test */ + function it_can_delete_a_nested_object_value() { + $object = (object) array( + 'foo' => (object) array( + 'bar' => 'baz', + ), + ); + $this->assertObjectHasAttribute( 'bar', $object->foo ); + + $traverser = new RecursiveDataStructureTraverser( $object ); + $traverser->delete( array( 'foo', 'bar' ) ); + + $this->assertObjectNotHasAttribute( 'bar', $object->foo ); + } + + /** @test */ + function it_can_insert_a_key_into_a_nested_array() { + $array = array( + 'foo' => array( + 'bar' => 'baz', + ), + ); + + $traverser = new RecursiveDataStructureTraverser( $array ); + $traverser->insert( array( 'foo', 'new' ), 'new value' ); + + $this->assertArrayHasKey( 'new', $array['foo'] ); + $this->assertEquals( 'new value', $array['foo']['new'] ); + } + + /** @test */ + function it_throws_an_exception_when_attempting_to_create_a_key_on_an_invalid_type() { + $data = 'a string'; + $traverser = new RecursiveDataStructureTraverser( $data ); + + try { + $traverser->insert( array( 'key' ), 'value' ); + } catch ( \Exception $e ) { + $this->assertSame( 'a string', $data ); + return; + } + + $this->fail( 'Failed to assert that an exception was thrown when inserting a key into a string.' ); + } + +}