Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@Taluu

Taluu/.gitignore Secret

Last active August 29, 2015 14:09
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Taluu/bf15da06f70aeacef91a to your computer and use it in GitHub Desktop.
Save Taluu/bf15da06f70aeacef91a to your computer and use it in GitHub Desktop.
POC Exporter
build
vendor
composer.lock
{
"require": {
"doctrine/inflector": "~1.0",
"doctrine/collections": "~1.2",
"symfony/property-access": "~2.5",
"symfony/dependency-injection": "~2.5"
},
"autoload": {
"psr-4": {
"Wisembly\\Poc\\": ["./"]
}
}
}
<?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);
}
}
<?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;
}
}
<?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, [], [], []);
}
}
<?php
namespace Wisembly\Poc;
interface ExportableInterface
{
/** @return Config */
public function getExportConfig();
}
<?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);
}
}
<?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);
}
<?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