Skip to content

Commit

Permalink
Initial introduction of classes
Browse files Browse the repository at this point in the history
  • Loading branch information
weaverryan committed Aug 17, 2023
1 parent ef312f3 commit 5b268e9
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 0 deletions.
31 changes: 31 additions & 0 deletions src/AsMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Symfonycasts\MicroMapper;

/**
* Attribute added to all "mapper" classes.
*
* Those classes should also implement MapperInterface.
*
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class AsMapper
{
public function __construct(
private string $from,
private string $to,
)
{
}

public function getFrom(): string
{
return $this->from;
}

public function getTo(): string
{
return $this->to;
}
}
32 changes: 32 additions & 0 deletions src/Bundle/DependencyInjection/MicroMapperCompilerPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Symfonycasts\MicroMapper\Bundle\DependencyInjection;

use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfonycasts\MicroMapper\MapperConfig;

/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class MicroMapperCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
$mapperConfigDefinitions = [];
foreach ($container->findTaggedServiceIds('micro_mapper.mapper') as $id => $tags) {
foreach ($tags as $tag) {
$mapperConfigDefinitions[] = new Definition(MapperConfig::class, [
$tag['from'],
$tag['to'],
new ServiceClosureArgument(new Reference($id))
]);
}
}
$microMapperDefinition = $container->findDefinition('symfonycasts.micro_mapper');
$microMapperDefinition->setArgument(0, $mapperConfigDefinitions);
}
}
53 changes: 53 additions & 0 deletions src/Bundle/DependencyInjection/MicroMapperExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Symfonycasts\MicroMapper\Bundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfonycasts\MicroMapper\AsMapper;

/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class MicroMapperExtension extends Extension implements ConfigurationInterface
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.php');

$container->registerAttributeForAutoconfiguration(AsMapper::class, static function (ChildDefinition $definition, AsMapper $attribute) {
$definition->addTag('micro_mapper.mapper', [
'from' => $attribute->getFrom(),
'to' => $attribute->getTo(),
]);
});
}

public function getConfiguration(array $config, ContainerBuilder $container): ?ConfigurationInterface
{
return $this;
}

public function getAlias(): string
{
return 'symfonycasts_micro_mapper';
}

public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('symfonycasts_micro_mapper');
$rootNode = $treeBuilder->getRootNode();
\assert($rootNode instanceof ArrayNodeDefinition);

// no configuration options yet

return $treeBuilder;
}
}
17 changes: 17 additions & 0 deletions src/Bundle/config/services.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

use Symfonycasts\MicroMapper\MicroMapper;
use Symfonycasts\MicroMapper\MicroMapperInterface;
use function Symfony\Component\DependencyInjection\Loader\Configurator\abstract_arg;

return static function (ContainerConfigurator $container): void {
$container->services()
->set('symfonycasts.micro_mapper', MicroMapper::class)
->args([
abstract_arg('mapper configs array'),
])
->alias(MicroMapperInterface::class, 'symfonycasts.micro_mapper')
;
};
27 changes: 27 additions & 0 deletions src/MapperConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Symfonycasts\MicroMapper;

/**
* Wrapper around an individual mapper.
*/
class MapperConfig
{
public function __construct(
private string $fromClass,
private string $toClass,
private \Closure $mapper
)
{
}

public function supports(object $fromObject, string $targetClass): bool
{
return $fromObject instanceof $this->fromClass && $this->toClass === $targetClass;
}

public function getMapper(): MapperInterface
{
return ($this->mapper)();
}
}
17 changes: 17 additions & 0 deletions src/MapperInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Symfonycasts\MicroMapper;

/**
* Interface needed for each individual mapper.
*
* Also add #[AsMapper(from: Foo:class, to: Bar:class)] to each mapper class.
*
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
interface MapperInterface
{
public function init(object $from, string $toClass, array $context): object;

public function populate(object $from, object $to, array $context): object;
}
74 changes: 74 additions & 0 deletions src/MicroMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Symfonycasts\MicroMapper;

/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class MicroMapper implements MicroMapperInterface
{
private array $objectHashes = [];

private int $currentDepth = 0;
private ?int $maxDepth = null;

/**
* @param MapperConfig[] $mapperConfigs
*/
public function __construct(private array $mapperConfigs)
{
}

public function map(object $from, string $toClass, array $context = []): object
{
$this->currentDepth++;

if ($this->currentDepth > 50) {
throw new \Exception('Max depth reached');
}

// set the max depth if not already set
// the max depth is recorded as MAX_DEPTH + the current depth
$previousMaxDepth = $this->maxDepth;
if (isset($context[self::MAX_DEPTH])
&& (null === $this->maxDepth || ($context[self::MAX_DEPTH] + $this->currentDepth) < $this->maxDepth)
) {
$this->maxDepth = $context[self::MAX_DEPTH] + $this->currentDepth;
}

$shouldFullyPopulate = $this->maxDepth === null || $this->currentDepth < $this->maxDepth;

// watch for circular references, but only if we're fully populating
// if we are not fully populating, this is already the final depth/level
// through the micro mapper.
if (isset($this->objectHashes[spl_object_hash($from)]) && $shouldFullyPopulate) {
throw new \Exception(sprintf(
'Circular reference detected with micro mapper: %s. Try passing [MicroMapperInterface::MAX_DEPTH => 1] when mapping relationships.',
implode(' -> ', array_merge($this->objectHashes, [get_class($from)]))
));
}

$this->objectHashes[spl_object_hash($from)] = get_class($from);

foreach ($this->mapperConfigs as $mapperConfig) {
if (!$mapperConfig->supports($from, $toClass)) {
continue;
}

$toObject = $mapperConfig->getMapper()->init($from, $toClass, $context);

// avoid fully populated objects if max depth is reached
if ($this->maxDepth === null || $this->currentDepth < $this->maxDepth) {
$mapperConfig->getMapper()->populate($from, $toObject, $context);
}

unset($this->objectHashes[spl_object_hash($from)]);
$this->currentDepth--;
$this->maxDepth = $previousMaxDepth;

return $toObject;
}

throw new \Exception(sprintf('No mapper found for %s -> %s', get_class($from), $toClass));
}
}
13 changes: 13 additions & 0 deletions src/MicroMapperInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Symfonycasts\MicroMapper;

/**
* Maps one object to another using the configured mappers.
*/
interface MicroMapperInterface
{
public const MAX_DEPTH = 'max_depth';

public function map(object $from, string $toClass, array $context = []): object;
}
18 changes: 18 additions & 0 deletions src/SymfonycastsMicroMapperBundle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Symfonycasts\MicroMapper;

use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfonycasts\MicroMapper\Bundle\DependencyInjection\MicroMapperExtension;

/**
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class SymfonycastsMicroMapperBundle extends Bundle
{
protected function createContainerExtension(): ?ExtensionInterface
{
return new MicroMapperExtension();
}
}

0 comments on commit 5b268e9

Please sign in to comment.