diff --git a/CHANGELOG.md b/CHANGELOG.md index 780944d895..f2aa82297c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## Unreleased + +* Added a DynamoDB `Marshaler` class, that allows you to marshal JSON documents + or native PHP arrays to the format that DynamoDB requires. You can also + unmarshal item data from operation results back into JSON documents or native + PHP arrays. + ## 2.7.6 - 2014-11-20 * Added support for AWS KMS integration to the Amazon Redshift Client. diff --git a/src/Aws/DynamoDb/Marshaler.php b/src/Aws/DynamoDb/Marshaler.php new file mode 100644 index 0000000000..096c5db551 --- /dev/null +++ b/src/Aws/DynamoDb/Marshaler.php @@ -0,0 +1,173 @@ +marshalValue($data)); + } + + /** + * Marshal a native PHP array of data to a new array that is formatted in + * the proper parameter structure required by DynamoDB operations. + * + * @param array|\stdClass $item An associative array of data. + * + * @return array + */ + public function marshalItem($item) + { + return current($this->marshalValue($item)); + } + + /** + * Marshal a native PHP value into an array that is formatted in the proper + * parameter structure required by DynamoDB operations. + * + * @param mixed $value A scalar, array, or stdClass value. + * + * @return array Formatted like `array(TYPE => VALUE)`. + * @throws \UnexpectedValueException if the value cannot be marshaled. + */ + private function marshalValue($value) + { + $type = gettype($value); + if ($type === 'string' && $value !== '') { + $type = 'S'; + } elseif ($type === 'integer' || $type === 'double') { + $type = 'N'; + $value = (string) $value; + } elseif ($type === 'boolean') { + $type = 'BOOL'; + } elseif ($type === 'NULL') { + $type = 'NULL'; + $value = true; + } elseif ($type === 'array' + || $value instanceof \Traversable + || $value instanceof \stdClass + ) { + $type = $value instanceof \stdClass ? 'M' : 'L'; + $data = array(); + $expectedIndex = -1; + foreach ($value as $k => $v) { + $data[$k] = $this->marshalValue($v); + if ($type === 'L' && (!is_int($k) || $k != ++$expectedIndex)) { + $type = 'M'; + } + } + $value = $data; + } else { + $type = $type === 'object' ? get_class($value) : $type; + throw new \UnexpectedValueException('Marshaling error: ' . ($value + ? "encountered unexpected type \"{$type}\"." + : 'encountered empty value.' + )); + } + + return array($type => $value); + } + + /** + * Unmarshal a document (item) from a DynamoDB operation result into a JSON + * document string. + * + * @param array $data Item/document from a DynamoDB result. + * @param int $jsonEncodeFlags Flags to use with `json_encode()`. + * + * @return string + */ + public function unmarshalJson(array $data, $jsonEncodeFlags = 0) + { + return json_encode( + $this->unmarshalValue(array('M' => $data), true), + $jsonEncodeFlags + ); + } + + /** + * Unmarshal an item from a DynamoDB operation result into a native PHP + * array. If you set $mapAsObject to true, then a stdClass value will be + * returned instead. + * + * @param array $data Item from a DynamoDB result. + * + * @return array|\stdClass + */ + public function unmarshalItem(array $data) + { + return $this->unmarshalValue(array('M' => $data)); + } + + /** + * Unmarshal a value from a DynamoDB operation result into a native PHP + * value. Will return a scalar, array, or (if you set $mapAsObject to true) + * stdClass value. + * + * @param array $value Value from a DynamoDB result. + * @param bool $mapAsObject Whether maps should be represented as stdClass. + * + * @return mixed + * @throws \UnexpectedValueException + */ + private function unmarshalValue(array $value, $mapAsObject = false) + { + list($type, $value) = each($value); + switch ($type) { + case 'S': + case 'SS': + case 'B': + case 'BS': + case 'BOOL': + return $value; + case 'NULL': + return null; + case 'N': + // Use type coercion to unmarshal numbers to int/float. + return $value + 0; + case 'NS': + foreach ($value as &$v) { + $v += 0; + } + return $value; + case 'M': + if ($mapAsObject) { + $data = new \stdClass; + foreach ($value as $k => $v) { + $data->$k = $this->unmarshalValue($v, $mapAsObject); + } + return $data; + } + // Else, unmarshal M the same way as L. + case 'L': + foreach ($value as &$v) { + $v = $this->unmarshalValue($v, $mapAsObject); + } + return $value; + } + + throw new \UnexpectedValueException("Unexpected type: {$type}."); + } +} diff --git a/tests/Aws/Tests/DynamoDb/Integration/DynamoDb_20120810_Test.php b/tests/Aws/Tests/DynamoDb/Integration/DynamoDb_20120810_Test.php index 82ae2a38bb..a0abf64459 100644 --- a/tests/Aws/Tests/DynamoDb/Integration/DynamoDb_20120810_Test.php +++ b/tests/Aws/Tests/DynamoDb/Integration/DynamoDb_20120810_Test.php @@ -18,6 +18,7 @@ use Aws\DynamoDb\DynamoDbClient; use Aws\DynamoDb\Iterator\ItemIterator; +use Aws\DynamoDb\Marshaler; /** * @group example @@ -203,22 +204,22 @@ public function testListTablesWithIterator() } /** - * Put an item in a table using the formatAttributes() client helper method + * Put an item in a table using the marshaler helper * * @depends testListTablesWithIterator * @example Aws\DynamoDb\DynamoDbClient::putItem 2012-08-10 - * @example Aws\DynamoDb\DynamoDbClient::formatAttributes 2012-08-10 */ public function testAddItem() { $client = $this->client; // @begin + $m = new Marshaler(); $time = time(); $result = $client->putItem(array( 'TableName' => 'errors', - 'Item' => $client->formatAttributes(array( + 'Item' => $m->marshalItem(array( 'id' => 1201, 'time' => $time, 'error' => 'Executive overflow', @@ -572,6 +573,41 @@ public function testDeleteItem() } } + /** + * Put a JSON document in a table using the marshaler helper + * + * @depends testWaitUntilTableExists + */ + public function testMarshaler() + { + $client = $this->client; + // @begin + + $m = new Marshaler(); + $time = time(); + $json = '{"id":1500,"time":'.$time.',"people":[{"name":"Jim","alive":tr' + . 'ue},{"name":"Bob","alive":false},{"name":"Amy","alive":true}]}'; + + $client->putItem(array( + 'TableName' => 'errors', + 'Item' => $m->marshalJson($json) + )); + + $result = $client->getItem(array( + 'TableName' => 'errors', + 'Key' => $m->marshalItem(array( + 'id' => 1500, + 'time' => $time + )) + )); + + // @end + $this->assertEquals( + json_decode($json, true), + json_decode($m->unmarshalJson($result['Item']), true) + ); + } + /** * Delete a table * diff --git a/tests/Aws/Tests/DynamoDb/MarshalerTest.php b/tests/Aws/Tests/DynamoDb/MarshalerTest.php new file mode 100644 index 0000000000..f5e5320016 --- /dev/null +++ b/tests/Aws/Tests/DynamoDb/MarshalerTest.php @@ -0,0 +1,299 @@ +callMethod($m, 'marshalValue', array($value)); + } catch (\UnexpectedValueException $e) { + $actualResult = self::ERROR; + } + $this->assertSame($expectedResult, $actualResult); + } + + public function getMarshalValueUseCases() + { + return array( + // "S" + array('S', array('S' => 'S')), + array('3', array('S' => '3')), + array('', self::ERROR), + + // "N" + array(1, array('N' => '1')), + array(-1, array('N' => '-1')), + array(0, array('N' => '0')), + array(5000000000, array('N' => '5000000000')), + array(1.23, array('N' => '1.23')), + array(1e10, array('N' => '10000000000')), + + // "BOOL" & "NULL" + array(true, array('BOOL' => true)), + array(false, array('BOOL' => false)), + array(null, array('NULL' => true)), + + // "L" + array( // Homogeneous + array(1, 2, 3), + array('L' => array( + array('N' => '1'), + array('N' => '2'), + array('N' => '3') + )) + ), + array( // Heterogeneous + array(1, 'one', true), + array('L' => array( + array('N' => '1'), + array('S' => 'one'), + array('BOOL' => true) + )) + ), + array( // Empty + array(), + array('L' => array()) + ), + array( // Traversable + new \ArrayObject(array(1, 2, 3)), + array('L' => array( + array('N' => '1'), + array('N' => '2'), + array('N' => '3') + )) + ), + + // "M" + array( // Associative array + array('foo' => 'foo', 'bar' => 3, 'baz' => null), + array('M' => array( + 'foo' => array('S' => 'foo'), + 'bar' => array('N' => '3'), + 'baz' => array('NULL' => true) + )) + ), + array( // Object + json_decode('{"foo":"foo","bar":3,"baz":null}'), + array('M' => array( + 'foo' => array('S' => 'foo'), + 'bar' => array('N' => '3'), + 'baz' => array('NULL' => true) + )) + ), + array( // Includes indexes + array('foo', 'bar', 'baz' => 'baz'), + array('M' => array( + '0' => array('S' => 'foo'), + '1' => array('S' => 'bar'), + 'baz' => array('S' => 'baz'), + )) + ), + array( // Empty + new \stdClass, + array('M' => array()) + ), + array( // Traversable + new \ArrayObject(array('foo' => 'foo', 'bar' => 3, 'baz' => null)), + array('M' => array( + 'foo' => array('S' => 'foo'), + 'bar' => array('N' => '3'), + 'baz' => array('NULL' => true) + )) + ), + + // Nested + array( + array( + 'name' => array( + 'first' => 'james', + 'middle' => array('michael', 'john'), + 'last' => 'richardson', + ), + 'colors' => array( + array('red' => 0, 'green' => 255, 'blue' => 255), + array('red' => 255, 'green' => 0, 'blue' => 127), + ) + ), + array('M' => array( + 'name' => array('M' => array( + 'first' => array('S' => 'james'), + 'middle' => array('L' => array( + array('S' => 'michael'), + array('S' => 'john'), + )), + 'last' => array('S' => 'richardson'), + )), + 'colors' => array('L' => array( + array('M' => array( + 'red' => array('N' => '0'), + 'green' => array('N' => '255'), + 'blue' => array('N' => '255'), + )), + array('M' => array( + 'red' => array('N' => '255'), + 'green' => array('N' => '0'), + 'blue' => array('N' => '127'), + )), + )) + )) + ), + + // Errors + array(new \SplFileInfo(__FILE__), self::ERROR), + array(fopen(__FILE__, 'r'), self::ERROR), + ); + } + + public function testMarshalingJsonAndItems() + { + $json = << 'string', + 'num' => 1, + 'bool' => true, + 'null' => null, + 'list' => array(1, 2, array(3, 4)), + 'map' => array('colors' => array('red', 'green', 'blue')), + ); + + $expected = array( + 'str' => array('S' => 'string'), + 'num' => array('N' => '1'), + 'bool' => array('BOOL' => true), + 'null' => array('NULL' => true), + 'list' => array('L' => array( + array('N' => '1'), + array('N' => '2'), + array('L' => array( + array('N' => '3'), + array('N' => '4'), + )), + )), + 'map' => array('M' => array( + 'colors' => array('L' => array( + array('S' => 'red'), + array('S' => 'green'), + array('S' => 'blue'), + )) + )), + ); + + $m = new Marshaler; + $this->assertEquals($expected, $m->marshalJson($json)); + $this->assertEquals($expected, $m->marshalItem($array)); + } + + public function testErrorIfMarshalingBadJsonDoc() + { + $m = new Marshaler; + $this->setExpectedException('InvalidArgumentException'); + $m->marshalJson('foo'); + } + + public function testUnmarshalingHandlesAllDynamoDbTypes() + { + $item = array( + 'S' => array('S' => 'S'), + 'N' => array('N' => '1'), + 'B' => array('B' => 'B'), + 'SS' => array('SS' => array('S', 'SS', 'SSS')), + 'NS' => array('NS' => array('1', '2', '3')), + 'BS' => array('BS' => array('B', 'BB', 'BBB')), + 'BOOL' => array('BOOL' => true), + 'NULL' => array('NULL' => true), + 'M' => array('M' => array( + 'A' => array('S' => 'A'), + 'B' => array('N' => '1'), + 'C' => array('BOOL' => true), + )), + 'L' => array('L' => array( + array('S' => 'A'), + array('N' => '1'), + array('BOOL' => true), + )) + ); + + $json = << 'S', + 'N' => 1, + 'B' => 'B', + 'SS' => array('S', 'SS', 'SSS'), + 'NS' => array(1, 2, 3), + 'BS' => array('B', 'BB', 'BBB'), + 'BOOL' => true, + 'NULL' => null, + 'M' => array('A' => 'A', 'B' => 1, 'C' => true), + 'L' => array('A', 1, true), + ); + + $m = new Marshaler; + $this->assertEquals($json, $m->unmarshalJson($item)); + $this->assertEquals($array, $m->unmarshalItem($item)); + } + + public function testCanUnmarshalToObjectFormat() + { + $m = new Marshaler; + $result = $this->callMethod($m, 'unmarshalValue', array( + array('M' => array('a' => array('S' => 'b'))), + true + )); + + $this->assertInstanceOf('stdClass', $result); + $this->assertEquals('b', $result->a); + } + + public function testErrorIfUnmarshalingUnknownType() + { + $m = new Marshaler; + $this->setExpectedException('UnexpectedValueException'); + $this->callMethod($m, 'unmarshalValue', array(array('BOMB' => 'BOOM'))); + } + + private function callMethod($object, $method, $args) + { + $o = new \ReflectionObject($object); + $m = $o->getMethod($method); + $m->setAccessible(true); + + return $m->invokeArgs($object, $args); + } +}