-
-
Save Taluu/bf15da06f70aeacef91a to your computer and use it in GitHub Desktop.
POC Exporter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
build | |
vendor | |
composer.lock |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"require": { | |
"doctrine/inflector": "~1.0", | |
"doctrine/collections": "~1.2", | |
"symfony/property-access": "~2.5", | |
"symfony/dependency-injection": "~2.5" | |
}, | |
"autoload": { | |
"psr-4": { | |
"Wisembly\\Poc\\": ["./"] | |
} | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Wisembly\Poc; | |
use InvalidArgumentException; | |
use Doctrine\Common\Inflector\Inflector; | |
/** | |
* This class represents an entity's configuration for the Exporter | |
*/ | |
class Config | |
{ | |
private $type; | |
private $defaults; | |
private $replacements; | |
private $excluded; | |
public function __construct($type, array $defaults, array $replacements = [], array $excluded = []) | |
{ | |
$this->type = $type; | |
$this->defaults = $defaults; | |
$this->replacements = $replacements; | |
$this->excluded = $excluded; | |
} | |
public function getType() | |
{ | |
return $this->type; | |
} | |
public function getDefaults() | |
{ | |
return $this->defaults; | |
} | |
public function getReplacement($key) | |
{ | |
if (!isset($this->replacements[$key])) { | |
throw new InvalidArgumentException('No replacement found for "' . $key . '"'); | |
} | |
return $this->replacements[$key]; | |
} | |
public function hasReplacement($key) | |
{ | |
return isset($this->replacements[$key]); | |
} | |
public function getExcluded() | |
{ | |
return $this->excluded; | |
} | |
public function isExcluded($key) | |
{ | |
return in_array(Inflector::camelize($key), $this->excluded); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Wisembly\Poc; | |
use InvalidArgumentException; | |
class Data | |
{ | |
/** | |
* Exported data for the main object | |
* | |
* @var array | |
*/ | |
private $data; | |
/** | |
* Exported data type (typically, the entity name) | |
* | |
* @var string | |
*/ | |
private $type; | |
/** | |
* The side content to load with the exported object | |
* | |
* @var self[] | |
*/ | |
private $sideLoadedContent = []; | |
/** | |
* @param mixed[] $data Exported data (it **must** have an accessible identifier !) | |
* @param string $type Data type name | |
*/ | |
public function __construct($type, array $data, array $sideLoadedContent = []) | |
{ | |
if (!isset($data['id'])) { | |
throw new InvalidArgumentException('The exported data **must** have an identifier, none given'); | |
} | |
$this->data = $data; | |
$this->type = $type; | |
$this->sideLoadedContent = $sideLoadedContent; | |
} | |
/** | |
* Clone this object | |
* | |
* in order to avoid to have too many overloaded objects, this magic method | |
* is here to transform this object into a true Value Object, as it should | |
* be (specially when this is flattened) | |
* | |
* This is for private use _only_ | |
*/ | |
private function __clone() | |
{ | |
$this->sideLoadedContent = []; | |
} | |
public function getData() | |
{ | |
return $this->data; | |
} | |
public function getType() | |
{ | |
return $this->type; | |
} | |
public function getIdentifier() | |
{ | |
return $this->data['id']; | |
} | |
/** | |
* Returns the side loaded content flattened (no more depths) | |
* | |
* The returned objects are just flattened, and do not contain any children | |
* anymore (payload reduced) | |
* | |
* @return ExportedObject[] | |
*/ | |
public function getSideLoadedContent() | |
{ | |
$sideLoaded = []; | |
foreach ($this->sideLoadedContent as $child) { | |
$key = sha1($child->getType() . serialize($child->getIdentifier())); | |
$sideLoaded[$key] = clone $child; | |
$sideLoaded = array_replace_recursive($sideLoaded, $child->getSideLoadedContent()); | |
} | |
return $sideLoaded; | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Wisembly\Poc; | |
use Countable, | |
Traversable, | |
LogicException, | |
InvalidArgumentException; | |
use Doctrine\Common\Collections\ArrayCollection; | |
class ExportableCollection extends ArrayCollection implements ExportableInterface | |
{ | |
private $raw; | |
private $type; | |
/** | |
* @param mixed $elements The collection | |
*/ | |
public function __construct($elements = []) | |
{ | |
$this->raw = $elements; | |
if ($elements instanceof Traversable) { | |
$elements = iterator_to_array($elements); | |
} | |
if (!is_array($elements)) { | |
throw new InvalidArgumentException('A collection should be either a traversable object or an array. "' . ('object' === gettype($elements) ? get_class($elements) : gettype($elements)) . '" given.'); | |
} | |
array_walk($elements, function ($element) { | |
if (!$element instanceof ExportableInterface) { | |
throw new InvalidArgumentException('This collection must only contain ExportableInterface elements'); | |
} | |
if (null === $this->type) { | |
$this->type = $element->getExportConfig()->getType(); | |
} | |
if ($element->getExportConfig()->getType() !== $this->type) { | |
throw new InvalidArgumentException(sprintf('The type of the element to add doesn\'t match. Expected "%s", got "%s"', $this->type, $element->getExportConfig()->getType())); | |
} | |
}); | |
parent::__construct($elements); | |
} | |
/** {@inheritDoc} */ | |
public function add($element) | |
{ | |
throw new LogicException('An ExportableCollection is a frozen collection ; you may not add new elements'); | |
} | |
/** {@inheritDoc} */ | |
public function set($key, $value) | |
{ | |
throw new LogicException('An ExportableCollection is a frozen collection ; you may not change any elements'); | |
} | |
/** {@inheritDoc} */ | |
public function clear() | |
{ | |
throw new LogicException('An ExportableCollection is a frozen collection ; you may not clear it'); | |
} | |
/** {@inheritDoc} */ | |
public function remove($key) | |
{ | |
throw new LogicException('An ExportableCollection is a frozen collection ; you may not remove any elements'); | |
} | |
/** {@inheritDoc} */ | |
public function removeElement($element) | |
{ | |
throw new LogicException('An ExportableCollection is a frozen collection ; you may not remove any elements'); | |
} | |
/** {@inheritDoc} */ | |
public function count() | |
{ | |
if ($this->raw instanceof Countable || is_array($this->raw)) { | |
return count($this->raw); | |
} | |
return parent::count(); | |
} | |
public function getRaw() | |
{ | |
return $this->raw; | |
} | |
public function getType() | |
{ | |
return $this->type; | |
} | |
public function getExportConfig() | |
{ | |
return new Config($this->type, [], [], []); | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Wisembly\Poc; | |
interface ExportableInterface | |
{ | |
/** @return Config */ | |
public function getExportConfig(); | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Wisembly\Poc; | |
use InvalidArgumentException; | |
use Symfony\Component\PropertyAccess\PropertyPath, | |
Symfony\Component\PropertyAccess\PropertyPathBuilder, | |
Symfony\Component\PropertyAccess\PropertyPathInterface, | |
Symfony\Component\PropertyAccess\PropertyAccessorInterface, | |
Symfony\Component\DependencyInjection\ContainerInterface; | |
use Doctrine\Common\Inflector\Inflector; | |
class Exporter | |
{ | |
/** @var PropertyAccessorInterface */ | |
private $accessor; | |
/** @var ContainerInterface */ | |
private $container; | |
/** @var string|PropertyPathInterface the identifier path used to identify uniquely a resource */ | |
private $identifier; | |
public function __construct(PropertyAccessorInterface $accessor, ContainerInterface $container = null, $identifier = 'id') | |
{ | |
$this->accessor = $accessor; | |
$this->container = $container; | |
if (!$identifier instanceof PropertyPathInterface) { | |
$identifier = new PropertyPath($identifier); | |
} | |
$this->identifier = $identifier; | |
} | |
/** | |
* Export an object into a JsonApi format | |
* | |
* Supports the nested includes, but the nested fields are only partially | |
* supported :( | |
* | |
* The following examples are based on a `Foo` object, which my be linked | |
* to zero or several `Bar` objects, which may also be linked to zero or | |
* more `Baz` objects. All these objects contains a 'fubar' property, which | |
* is an associative array containing a `fubaz` property. | |
* | |
* Examples of expected fields : | |
* | |
* ```json | |
* { | |
* "fields": { | |
* "foo": ["fubar"], | |
* "bar": ["fubar"], | |
* "baz": [] | |
* } | |
* } | |
* ``` | |
* | |
* But the following is not fully supported (... yet ?) : | |
* | |
* ```json | |
* { | |
* "fields": { | |
* "foo": ["fubar.fubaz"], | |
* "foo": ["fubar[fubaz]"] | |
* } | |
* } | |
* ``` | |
* | |
* These are not supported because in the returned payload (see below), we | |
* can't correctly pinpoint the correct field to return (we would have a | |
* `fubar.fubaz` key, not only the `fubar` key which has a `fubaz` nested | |
* key. | |
* | |
* Regarding includes, as the nested includes are **fully** supported, the | |
* following examples are **both** valid : | |
* | |
* ```json | |
* { | |
* "includes": ["bar"] | |
* } | |
* ``` | |
* | |
* ```json | |
* { | |
* "includes": ["bar.baz"] | |
* } | |
* | |
* | |
* If we combine the two examples, here is the returned payload : | |
* | |
* ```json | |
* { | |
* "foo": {"id": "fubaz", "fubar": ['a', 'value']}, | |
* "bars": [{"id": "fubaz", "fubar": ['another', 'value']}], | |
* "bazs": [{"id": "fubaz", "fubar": ['expect', 'this']}] | |
* } | |
* ``` | |
* | |
* Note that if the main value is an array, the `foo` key should be | |
* pluralized, or singular otherwise, as for the included resources, they | |
* will **always** be pluralized | |
* | |
* @param ExportableInterface $object Object to export | |
* @param integer $mutator Level of mutation, if it applies | |
* @param string[][] fields to export | |
* @param string[] resources to include with this export | |
* | |
* @return array Exported data | |
*/ | |
public function export(ExportableInterface $object, $mutator = MutableInterface::MUTATE_NONE, array $fields = [], array $includes = []) | |
{ | |
$data = []; | |
$export = []; | |
if ($object instanceof ExportableCollection) { | |
$key = Inflector::pluralize($object->getExportConfig()->getType()); | |
foreach ($object as $element) { | |
$data[] = $this->transform($element, $mutator, $fields, $includes); | |
} | |
} else { | |
$key = Inflector::singularize($object->getExportConfig()->getType()); | |
$data[] = $this->transform($object, $mutator, $fields, $includes); | |
} | |
$export[$key] = []; | |
foreach ($data as $transformed) { | |
$export[$key][] = $transformed->getData(); | |
foreach ($transformed->getSideLoadedContent() as $sideLoadedContent) { | |
$sideLoadedKey = Inflector::pluralize($sideLoadedContent->getType()); | |
if (!isset($export[$sideLoadedKey])) { | |
$export[$sideLoadedKey] = []; | |
} | |
$export[$sideLoadedKey][] = $sideLoadedContent->getData(); | |
} | |
} | |
if (!$object instanceof ExportableCollection) { | |
$export[$key] = array_pop($export[$key]); | |
} | |
return $export; | |
} | |
/** | |
* Transforms the data as a Data object | |
* | |
* For the expected values for `$fields` and `$includes`, please see the | |
* above speech (as I'm too lazy to make a redo). | |
* | |
* @param ExportableInterface $object Object to export | |
* @param integer $mutator Level of mutation, if it applies | |
* @param string[][] fields to export | |
* @param string[] resources to include with this export | |
* | |
* @return Data | |
*/ | |
private function transform(ExportableInterface $object, $mutator = MutableInterface::MUTATE_NONE, array $fields = [], array $includes = []) | |
{ | |
$config = $object->getExportConfig(); | |
if (!isset($fields[$config->getType()]) || empty($fields[$config->getType()])) { | |
$fields[$config->getType()] = $config->getDefaults(); | |
} | |
$export = ['id' => $this->accessor->getValue($object, $this->identifier)]; | |
foreach ($fields[$config->getType()] as $field) { | |
if ($config->isExcluded((string) $field)) { | |
throw new InvalidArgumentException(sprintf('The field "%s" is restricted', $field)); | |
} | |
$value = $this->accessor->getValue($object, $config->hasReplacement($field) ? $config->getReplacement($field) : $field); | |
if ($value instanceof Datetime) { | |
$value = $value->format('c'); | |
} | |
$export[$field] = $value; | |
} | |
if (null !== $this->container && $object instanceof MutableInterface && MutableInterface::MUTATE_NONE !== $mutator) { | |
$export = $object->mutate($this->container, $export, $mutator); | |
} | |
$meta = []; | |
/* | |
* The `$includes` array has all the included fields asked. If it was | |
* this simple, this comment shouldn't be here, but as you may see, here | |
* it is. | |
* | |
* There can be several nested included resources, such as `foo.bar` and | |
* `foo.baz` ; we do not want to export the `foo` object two times for | |
* two different fields (it wouldn't make any senses, would it ?) | |
* | |
* To solve this problem, we need to go through the `$includes` array | |
* two times, in order to extract all the root nodes, so that we may | |
* export the things recursively and only once | |
*/ | |
foreach ($includes as $include) { | |
$path = new PropertyPath($include); | |
$root = $path->getElement(0); | |
if ($path->isIndex(0)) { | |
$root = sprintf('[%s]', $root); | |
} | |
if (!isset($meta[$root])) { | |
$meta[$root] = []; | |
} | |
if (1 < $path->getLength()) { | |
$builder = new PropertyPathBuilder($path); | |
$builder->remove(0, 1); | |
$meta[$root][] = (string) $builder; | |
} | |
} | |
$sideLoad = []; | |
// Now we can load everything on demand | |
foreach ($meta as $field => $nest) { | |
if ($config->isExcluded((string) $field)) { | |
throw new InvalidArgumentException(sprintf('The field "%s" is restricted', $field)); | |
} | |
$value = $this->accessor->getValue($object, $field); | |
if (is_array($value) || $value instanceof Traversable) { | |
$export[$field] = []; | |
foreach ($value as $element) { | |
if (!$element instanceof ExportableInterface) { | |
throw new InvalidArgumentException('Expected something that implements "Wisembly\\Poc\\ExportableInterface"'); | |
} | |
$export[$field][] = $this->accessor->getValue($element, $this->identifier); | |
$sideLoad[] = $this->transform($element, $mutator, $fields, $nest); | |
} | |
// avoid duplicata | |
$export[$field] = array_unique($export[$field]); | |
} else { | |
if (!$value instanceof ExportableInterface) { | |
throw new InvalidArgumentException('Expected something that implements "Wisembly\\Poc\\ExportableInterface"'); | |
} | |
$export[$field] = $this->accessor->getValue($value, $this->identifier); | |
$sideLoad[] = $this->transform($value, $mutator, $fields, $nest); | |
} | |
} | |
return new Data($config->getType(), $export, $sideLoad); | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Wisembly\Poc; | |
use Symfony\Component\DependencyInjection\ContainerInterface; | |
interface MutableInterface extends ExportableInterface | |
{ | |
const MUTATE_NONE = 0b00; | |
const MUTATE_HYDRATE = 0b01; | |
const MUTATE_CONTEXT = 0b10; | |
const MUTATE_ALL = 0b11; | |
/** | |
* Mutate the data to send, with synfony's container | |
* | |
* @param array $data Exported data | |
* @param integer $mutator Level of mutation to apply | |
* | |
* @return array Data mutated | |
*/ | |
public function mutate(ContainerInterface $container, array $data, $mutator = self::MUTATE_NONE); | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Wisembly\Poc; | |
use Symfony\Component\PropertyAccess\PropertyAccess, | |
Symfony\Component\DependencyInjection\Container, | |
Symfony\Component\DependencyInjection\ContainerInterface; | |
require __DIR__ . "/vendor/autoload.php"; | |
abstract class Base implements ExportableInterface | |
{ | |
private $id = 0; | |
private static $i = 0; | |
public $fubar = 'random'; | |
public function __construct() | |
{ | |
$this->id = ++self::$i; | |
} | |
public function getId() | |
{ | |
return sprintf('%s:%d', substr(static::CLASS, strrpos(static::CLASS, '\\') + 1), $this->id); | |
} | |
public function getClass() | |
{ | |
return static::CLASS; | |
} | |
public function getExportConfig() | |
{ | |
return new Config(strtolower(substr(static::CLASS, strrpos(static::CLASS, '\\') + 1)), ['class']); | |
} | |
public function getCamelizedThing() | |
{ | |
return 42; | |
} | |
abstract public function getTitle(); | |
} | |
// simple object | |
class Foo extends Base | |
{ | |
public function getTitle() | |
{ | |
return 'simple object'; | |
} | |
} | |
// object with a dependency | |
class Bar extends Base | |
{ | |
private $foo; | |
public function __construct(Foo $foo) | |
{ | |
parent::__construct(); | |
$this->foo = $foo; | |
} | |
public function getFoo() | |
{ | |
return $this->foo; | |
} | |
public function getTitle() | |
{ | |
return 'object with a dependency'; | |
} | |
} | |
// object with a multiple set of dependencies | |
class Baz extends Base | |
{ | |
private $foos = []; | |
public function addFoo(Foo $foo) | |
{ | |
$this->foos[] = $foo; | |
} | |
public function getFoos() | |
{ | |
return $this->foos; | |
} | |
public function getTitle() | |
{ | |
return 'object with a collection of dependencies'; | |
} | |
} | |
// object with a nested dependency | |
class Qux extends Base | |
{ | |
public function __construct(Bar $bar) | |
{ | |
parent::__construct(); | |
$this->bar = $bar; | |
} | |
public function getBar() | |
{ | |
return $this->bar; | |
} | |
public function getTitle() | |
{ | |
return 'object with a nested dependency'; | |
} | |
} | |
class Fubar extends Base implements MutableInterface | |
{ | |
public function mutate(ContainerInterface $container, array $data, $mutator = MutableInterface::MUTATE_NONE) | |
{ | |
$refl = new \ReflectionClass(MutableInterface::CLASS); | |
$level = null; | |
foreach ($refl->getConstants() as $name => $value) { | |
if ('MUTATE_' === substr($name, 0, 7) && $mutator === $value) { | |
$level = strtolower(substr($name, 7)); | |
break; | |
} | |
} | |
$data['mutated'] = true; | |
$data['mutation_level'] = $level; | |
return $data; | |
} | |
public function getTitle() | |
{ | |
return 'object to be mutated'; | |
} | |
} | |
$exporter = new Exporter(PropertyAccess::createPropertyAccessor(), new Container, 'id'); | |
$foo = new Foo; | |
$bar = new Bar(new Foo); | |
$qux = new Qux(new Bar(new Foo)); | |
$fubar = new Fubar; | |
$baz = new Baz; | |
$baz->addFoo($foo); | |
$baz->addFoo($foo); | |
$baz->addFoo(new Foo); | |
$collection = new ExportableCollection([new Foo, new Foo, new Foo]); | |
var_dump($exporter->export($foo, MutableInterface::MUTATE_NONE, ['foo' => ['class', 'title', 'fubar', 'camelized_thing']])); | |
var_dump($exporter->export($bar, MutableInterface::MUTATE_NONE, ['bar' => ['class', 'title'], 'foo' => ['title']], ['foo'])); | |
var_dump($exporter->export($baz, MutableInterface::MUTATE_NONE, ['baz' => ['class', 'title'], 'foo' => ['title']], ['foos'])); | |
var_dump($exporter->export($qux, MutableInterface::MUTATE_NONE, ['qux' => ['class', 'title'], 'bar' => ['title'], 'foo' => ['title']], ['bar.foo'])); | |
var_dump($exporter->export($collection, MutableInterface::MUTATE_NONE, ['foo' => ['class', 'title']])); | |
// mutated things | |
var_dump($exporter->export($fubar, MutableInterface::MUTATE_NONE)); | |
var_dump($exporter->export($fubar, MutableInterface::MUTATE_CONTEXT)); | |
var_dump($exporter->export($fubar, MutableInterface::MUTATE_HYDRATE)); | |
var_dump($exporter->export($fubar, MutableInterface::MUTATE_ALL)); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment