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.' );
+ }
+
+}