Skip to content

Commit

Permalink
Add support for $each (#3)
Browse files Browse the repository at this point in the history
Add support for an $each operator
  • Loading branch information
tburry authored Sep 4, 2019
1 parent ee86dae commit ac30cb6
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 21 deletions.
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,62 @@ If you reference a key that doesn't exist in the source data then it will be omi

If you omit the `$default` value then the default value is assumed to be `null`.

### Absolute vs. Relative References
### Transforming arrays with $each

You may have noticed that the references in the examples above all start with a `/` character. This is because they are all *absolute* references. If you don't use a `/` at the beginning of your reference then you are specifying a *relative* reference.
You can loop through an array and transform each item using `$each` and `$item`.

*Currently, there is no specific functionality for relative references, but it is recommended that you stick with absolute references to ensure forwards compatibility.*
```json
{
"$each": "/",
"$item": {
"name": "username",
"id": "userID"
}
}
```

The above would transform an array like this:

```json
[
{ "username": "bot", "userID": 1 },
{ "username": "dog", "userID": 2 }
]
```

Into this:

```json
[
{ "name": "bot", "id": 1 },
{ "name": "dog", "id": 2 }
]
```

You can aso specify a transform for the keys in an array using the `$key` attribute. Here is an example spec:

```json
{
"$each": "/",
"$item": "userID",
"$key": "username"
}
```

This spec would transform the example from above into this:

```json
{
"bot": 1,
"dog": 2
}
```

#### Absolute vs. Relative References

You may have noticed that the references most of the examples all start with a `/` character. This is because they are all *absolute* references.

If you don't use a `/` at the beginning of your reference then you are specifying a *relative* reference. You use relative references in the `$item` spec to refer to items within the loop.

### Literal Values

Expand Down
2 changes: 1 addition & 1 deletion src/InvalidSpecException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* @author Todd Burry <[email protected]>
* @copyright 2009-2019 Vanilla Forums Inc.
* @license Proprietary
* @license MIT
*/

namespace Garden\JSON;
Expand Down
84 changes: 68 additions & 16 deletions src/Transformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@
*/
final class Transformer {
/**
* @var array The transform spec.
* @var string|array The transform spec.
*/
private $spec;

/**
* Transformer constructor.
*
* @param array $spec The transformation spec.
* @param array|string $spec The transformation spec.
*/
public function __construct(array $spec) {
public function __construct($spec) {
$this->spec = $spec;
}

Expand All @@ -33,7 +33,7 @@ public function __construct(array $spec) {
* @throws InvalidSpecException Throws an exception if the spec has an error.
*/
public function transform($data) {
$r = $this->transformInternal($this->spec, $data);
$r = $this->transformInternal($this->spec, $data, $data, '/');

return $r;
}
Expand All @@ -52,28 +52,32 @@ public function __invoke($data) {
/**
* Transform a spec node.
*
* @param array $spec The spec node to transform.
* @param array|array $spec The spec node to transform.
* @param array $data The data to transform.
* @param array $root The root of the data from the first call to `transform()`.
* @param string $path The current spec path being transformed.
* @return mixed Returns the transformed data.
* @throws InvalidSpecException Throws an exception if the spec has an error.
*/
private function transformInternal(array $spec, array $data, string $path = '/') {
$result = [];
private function transformInternal($spec, $data, $root, string $path) {
if (is_string($spec) || is_int($spec)) {
return $this->resolveReference($spec, $data, $root, $found);
}

$result = [];
foreach ($spec as $key => $value) {
if (substr($key, 0, 1) === '$') {
// This is a control expression; resolve it.
$result = $this->resolveControlExpression($key, $spec, $data, $path);
$result = $this->resolveControlExpression($key, $spec, $data, $root, $path);
break;
} elseif (is_string($value) || is_int($value)) {
// This is a reference; look it up.
$r = $this->resolveReference($value, $data, $data, $found);
$r = $this->resolveReference($value, $data, $root, $found);
if ($found) {
$result[$key] = $r;
}
} elseif (is_array($value)) {
$result[$key] = $this->transformInternal($value, $data, $path.static::escapeRef($key).'/');
$result[$key] = $this->transformInternal($value, $data, $root, $path . static::escapeRef($key) . '/');
} else {
$subpath = $path.static::escapeRef($key);

Expand All @@ -88,23 +92,29 @@ private function transformInternal(array $spec, array $data, string $path = '/')
* Resolve a JSON reference.
*
* @param string $ref The reference to resolve.
* @param array $context The current data context to lookup.
* @param array $root The root data context for absolute references.
* @param mixed $context The current data context to lookup.
* @param mixed $root The root data context for absolute references.
* @param bool $found Set to **true** if the reference was found or **false** otherwise.
* @return mixed Returns the value at the reference.
*/
private function resolveReference(string $ref, array $context, array $root, bool &$found = null) {
private function resolveReference(string $ref, $context, $root, bool &$found = null) {
$found = true;

if ($ref === '') {
return $context;
} elseif ($ref === '/') {
return $root;
} elseif ($ref[0] === '/') {
$ref = substr($ref, 1);
$context = $root;
}

$parts = self::explodeRef($ref);
if (!is_array($context)) {
$found = false;
return null;
}

$parts = self::explodeRef($ref);
$result = $context;
foreach ($parts as $key) {
if (array_key_exists($key, $result)) {
Expand Down Expand Up @@ -154,23 +164,65 @@ private static function explodeRef(string $ref): array {
* @param string $expr The expression to resolve.
* @param array $spec The spec node where the expression was found.
* @param array $data The data to lookup.
* @param array $root The root of the data for absolute references.
* @param string $path The current spec path being looked at.
* @return mixed Returns the resolved expression.
* @throws InvalidSpecException Throws an exception if the spec has an error.
*/
private function resolveControlExpression(string $expr, array $spec, array $data, string $path) {
private function resolveControlExpression(string $expr, array $spec, array $data, array $root, string $path) {
switch ($expr) {
case '$ref':
case '$default':
$result = $this->resolveReference($spec['$ref'] ?? null, $data, $data, $found);
$result = $this->resolveReference($spec['$ref'] ?? null, $data, $root, $found);
if (!$found) {
$result = $spec['$default'] ?? null;
}
return $result;
case '$each':
case '$item':
case '$index':
$result = $this->resolveEach($spec, $data, $root, $path);
return $result;
case '$literal':
return $spec['$literal'];
default:
throw new InvalidSpecException("Invalid control expression \"$expr\" at $path");
}
}

private function resolveEach(array $spec, array $data, array $root, string $path) {
if (!array_key_exists('$each', $spec)) {
throw new InvalidSpecException("Missing key \$each at $path.");
}
if (!array_key_exists('$item', $spec)) {
throw new InvalidSpecException("Missing key \$item at $path.");
}

$each = $this->resolveReference($spec['$each'] ?? null, $data, $root, $found);
$itemSpec = $spec['$item'];
$keySpec = $spec['$key'] ?? '$key';

if (!$found) {
return null;
}

$result = [];
$index = 0;
foreach ($each as $i => $item) {
$subPath = $path.static::escapeRef($i).'/';

if ($keySpec === '$key') {
$key = $i;
} elseif ($keySpec === '$index') {
$key = $index;
} else {
$key = $this->transformInternal($keySpec, $item, $root, $subPath);
}


$result[$key] = $this->transformInternal($itemSpec, $item, $root, $subPath);
$index++;
}
return $result;
}
}
8 changes: 7 additions & 1 deletion tests/BasicTransformsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
* @author Todd Burry <[email protected]>
* @copyright 2009-2019 Vanilla Forums Inc.
* @license Proprietary
* @license MIT
*/

namespace Garden\JSON\Tests;
Expand Down Expand Up @@ -111,4 +111,10 @@ public function testNumericRelativeArray() {
$actual = $t->transform(['a', 'b']);
$this->assertSame(['b', 'a'], $actual);
}

public function testNonArrayContext() {
$t = new Transformer('/foo');
$actual = $t('baz');
$this->assertSame(null, $actual);
}
}
92 changes: 92 additions & 0 deletions tests/EachTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php
/**
* @author Todd Burry <[email protected]>
* @copyright 2009-2019 Vanilla Forums Inc.
* @license MIT
*/

namespace Garden\JSON\Tests;

use Garden\JSON\Transformer;
use PHPUnit\Framework\TestCase;

class EachTest extends TestCase {
/**
* Arrays can be iterated over with the `$each` expression.
*/
public function testBasicEach() {
$t = new Transformer([
'$each' => '/',
'$item' => [
'id' => 'ID',
],
]);

$actual = $t->transform([
'a' => ['ID' => 1],
'b' => ['ID' => 2]
]);


$this->assertSame(['a' => ['id' => 1], 'b' => ['id' => 2]], $actual);
}

/**
* Strip string keys.
*/
public function testIndexEach() {
$t = new Transformer([
'$each' => '/',
'$item' => '',
'$key' => '$index',
]);

$actual = $t(['a' => 'a', 'b' => 'b']);
$this->assertSame(['a', 'b'], $actual);
}

/**
* The key can also be a spec.
*/
public function testKeySpec() {
$t = new Transformer([
'$each' => '/',
'$item' => 'id',
'$key' => 'name'
]);

$actual = $t([['id' => 1, 'name' => 'foo']]);
$this->assertSame(['foo' => 1], $actual);
}

/**
* An `$item` without `$each` is an exception.
*
* @expectedException \Garden\JSON\InvalidSpecException
* @expectedExceptionMessageRegExp `^Missing key \$each at /`
*/
public function testMissingEach() {
$t = new Transformer(['$item' => 'b']);
$t([]);
}

/**
* An `$item` without `$each` is an exception.
*
* @expectedException \Garden\JSON\InvalidSpecException
* @expectedExceptionMessageRegExp `^Missing key \$item at /`
*/
public function testMissingItem() {
$t = new Transformer(['$each' => 'b']);
$t([]);
}

/**
* An each reference that doesn't resolve should return null.
*/
public function testEachNotFound() {
$t = new Transformer(['$each' => 'a', '$item' => 'b']);
$actual = $t(['fff']);
$this->assertSame(null, $actual);
}
}

0 comments on commit ac30cb6

Please sign in to comment.