Skip to content

Commit

Permalink
Merge pull request #1 from floriandammeyer/hydration-strategies
Browse files Browse the repository at this point in the history
Hydration strategies
  • Loading branch information
yesdevnull authored Aug 26, 2018
2 parents 31e3087 + c63147e commit f437455
Show file tree
Hide file tree
Showing 9 changed files with 276 additions and 13 deletions.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ We have provided a nifty way for you to do this in your tests. PHPUnit provides

The `loadFactories` function will recurse through all sub-folders of the path you specify when searching for factory files, except for hidden folders (i.e. starting with a .) which will be ignored. It will also throw a `League\FactoryMuffin\Exceptions\DirectoryNotFoundException` exception if the factories directory you specify is not found.

### Model Hydration

If your model classes use public setter methods or public properties, FactoryMuffin can automatically hydrate your models using its default hydration strategy `League\FactoryMuffin\HydrationStrategies\PublicSetterHydrationStrategy`.

However, if your models use completely different ways to access their properties, you can implement your own hydration strategies and register them per model.
You simply need to implement the interface `League\FactoryMuffin\HydrationStrategies\HydrationStrategyInterface` and register an instance of your strategy with the class name of your model `$fm->setHydrationStrategy('Fully\Qualified\ModelName', $my_custom_strategy);`.

For convenience, FactoryMuffin already ships with an alternative hydration strategy called `League\FactoryMuffin\HydrationStrategies\ReflectionHydrationStrategy`.
As the name suggests, this strategy uses reflection to set the attributes directly, which allows it to set `private/protected` properties. You simply need to register an instance of this strategy with every model that you want to be hydrated by this strategy.

### Creation/Instantiation Callbacks

You may optionally specify a callback to be executed on model creation/instantiation as a third parameter when defining a definition. We will pass your model instance as the first parameter to the closure if you specify one. We additionally pass a boolean as the second parameter that will be `true` if the model is being persisted to the database (the create function was used), and `false` if it's not being persisted (the instance function was used). We're using the `isPendingOrSaved` function under the hood here. Note that if you specify a callback and use the create function, we will try to save your model to the database both before and after we execute the callback.
Expand Down
68 changes: 56 additions & 12 deletions src/FactoryMuffin.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use League\FactoryMuffin\Exceptions\DirectoryNotFoundException;
use League\FactoryMuffin\Exceptions\ModelNotFoundException;
use League\FactoryMuffin\Generators\GeneratorFactory;
use League\FactoryMuffin\HydrationStrategies\HydrationStrategyInterface;
use League\FactoryMuffin\HydrationStrategies\PublicSetterHydrationStrategy;
use League\FactoryMuffin\Stores\ModelStore;
use League\FactoryMuffin\Stores\StoreInterface;
use RecursiveDirectoryIterator;
Expand Down Expand Up @@ -55,17 +57,32 @@ class FactoryMuffin
protected $factory;

/**
* Create a new factory muffin instance.
* The array of registered hydration strategies.
*
* @param \League\FactoryMuffin\Stores\StoreInterface|null $store The store instance.
* @param \League\FactoryMuffin\Generators\GeneratorFactory|null $factory The generator factory instance.
* @var \League\FactoryMuffin\HydrationStrategies\HydrationStrategyInterface[]
*/
private $hydration_strategies = [];

/**
* The default hydration strategy instance that will be used if no specialized
* hydration strategy has been registered for a model class.
*
* @return void
* @var \League\FactoryMuffin\HydrationStrategies\HydrationStrategyInterface
*/
private $default_hydration_strategy;

/**
* Create a new factory muffin instance.
*
* @param \League\FactoryMuffin\Stores\StoreInterface|null $store The store instance.
* @param \League\FactoryMuffin\Generators\GeneratorFactory|null $factory The generator factory instance.
* @param \League\FactoryMuffin\HydrationStrategies\HydrationStrategyInterface|null $default_hydration_strategy The default hydration strategy instance.
*/
public function __construct(StoreInterface $store = null, GeneratorFactory $factory = null)
public function __construct(StoreInterface $store = null, GeneratorFactory $factory = null, HydrationStrategyInterface $default_hydration_strategy = null)
{
$this->store = $store ?: new ModelStore();
$this->factory = $factory ?: new GeneratorFactory();
$this->default_hydration_strategy = $default_hydration_strategy ?: new PublicSetterHydrationStrategy();
}

/**
Expand Down Expand Up @@ -236,18 +253,45 @@ public function instance($name, array $attr = [])
*/
protected function generate($model, array $attr = [])
{
// Get the hydration strategy that has been
// registered for the given model class
$hydration_strategy = $this->getHydrationStrategy(get_class($model));

foreach ($attr as $key => $kind) {
$value = $this->factory->generate($kind, $model, $this);

$setter = 'set'.ucfirst(static::camelize($key));
$hydration_strategy->set($model, $key, $value);
}
}

// check if there is a setter and use it instead
if (method_exists($model, $setter) && is_callable([$model, $setter])) {
$model->$setter($value);
} else {
$model->$key = $value;
}
/**
* Register a hydration strategy instance that will be used
* to hydrate all models of the given class.
*
* @param string $name The class name of the model.
* @param HydrationStrategyInterface $strategy
*/
public function setHydrationStrategy($name, HydrationStrategyInterface $strategy)
{
$this->hydration_strategies[$name] = $strategy;
}

/**
* Get the hydration strategy for the given model class.
*
* If no specific hydration strategy has been registered, the default strategy will be returned.
*
* @param string $name
*
* @return HydrationStrategyInterface
*/
public function getHydrationStrategy($name)
{
if (array_key_exists($name, $this->hydration_strategies)) {
return $this->hydration_strategies[$name];
}

return $this->default_hydration_strategy;
}

/**
Expand Down
21 changes: 21 additions & 0 deletions src/HydrationStrategies/HydrationStrategyInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace League\FactoryMuffin\HydrationStrategies;

/**
* Interface for defining strategies to hydrate a model's attributes.
*
* @author Florian Dammeyer <[email protected]>
*/
interface HydrationStrategyInterface
{
/**
* Set the attribute with the given key on
* the given object to the given value.
*
* @param object $model The model instance to set the attribute on.
* @param string $key The key of the attribute to be set.
* @param mixed $value The new value for the given attribute.
*/
public function set($model, $key, $value);
}
51 changes: 51 additions & 0 deletions src/HydrationStrategies/PublicSetterHydrationStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace League\FactoryMuffin\HydrationStrategies;

/**
* A hydration strategy that uses public setter methods
* or alternatively public property access.
*
* This has been the hardcoded way to hydrate models
* before hydration strategies were implemented.
*
* @author Florian Dammeyer <[email protected]>
*/
class PublicSetterHydrationStrategy implements HydrationStrategyInterface
{
/**
* Set the given attribute by using a public setter method
* or public property access if possible.
*
* @param object $model
* @param string $key
* @param mixed $value
*/
public function set($model, $key, $value)
{
$setter = 'set'.ucfirst(static::camelize($key));

// check if there is a setter and use it instead
if (method_exists($model, $setter) && is_callable([$model, $setter])) {
$model->$setter($value);
} else {
$model->$key = $value;
}
}

/**
* Camelize string.
*
* Transforms a string to camel case (e.g. first_name -> firstName).
*
* @param string $str String in underscore format.
*
* @return string
*/
protected static function camelize($str)
{
return preg_replace_callback('/_([a-z0-9])/', function ($c) {
return strtoupper($c[1]);
}, $str);
}
}
24 changes: 24 additions & 0 deletions src/HydrationStrategies/ReflectionHydrationStrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace League\FactoryMuffin\HydrationStrategies;

use ReflectionProperty;

/**
* A hydration strategy that uses reflection to change properties directly.
*
* Reflection enables this strategy to set private and protected properties
* without the need of public setter methods.
*
* @author Florian Dammeyer <[email protected]>
*/
class ReflectionHydrationStrategy implements HydrationStrategyInterface
{
public function set($model, $key, $value)
{
$property = new ReflectionProperty(get_class($model), $key);
$property->setAccessible(true);
$property->setValue($model, $value);
$property->setAccessible(false);
}
}
3 changes: 3 additions & 0 deletions tests/AbstractTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
*/
abstract class AbstractTestCase extends PHPUnit_Framework_TestCase
{
/**
* @var FactoryMuffin
*/
protected static $fm;

public static function setupBeforeClass()
Expand Down
2 changes: 1 addition & 1 deletion tests/DefinitionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function testGetDefinitions()
{
$definitions = static::$fm->getDefinitions();

$this->assertCount(39, $definitions);
$this->assertCount(40, $definitions);
}

public function testBasicDefinitionFunctions()
Expand Down
100 changes: 100 additions & 0 deletions tests/HydrationStrategyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

use League\FactoryMuffin\HydrationStrategies\PublicSetterHydrationStrategy;
use League\FactoryMuffin\HydrationStrategies\ReflectionHydrationStrategy;
use Prophecy\Argument;

class HydrationStrategyTest extends AbstractTestCase
{
public function testHydrationStrategyIsBeingUsed()
{
$strategy = $this->prophesize('League\FactoryMuffin\HydrationStrategies\HydrationStrategyInterface');

static::$fm->setHydrationStrategy('FakerHydrationModel', $strategy->reveal());
static::$fm->instance('FakerHydrationModel');

$strategy->set(Argument::type('FakerHydrationModel'), Argument::type('string'), Argument::any())
->shouldBeCalledTimes(3);
}

public function testPublicSetterHydration()
{
$strategy = new PublicSetterHydrationStrategy();

$model = new ModelWithPublicSetters();

$strategy->set($model, 'value', 'Test value');
$strategy->set($model, 'separated_value', 'Another test value');

$this->assertEquals($model->getValue(), 'Test value');
$this->assertEquals($model->getSeparatedValue(), 'Another test value');
}

public function testPublicAttributesHydration()
{
$strategy = new PublicSetterHydrationStrategy();

$model = new ModelWithPublicAttributes();

$strategy->set($model, 'value', 'Test value');
$strategy->set($model, 'separated_value', 'Another test value');

$this->assertEquals($model->value, 'Test value');
$this->assertEquals($model->separated_value, 'Another test value');
}

public function testProtectedAttributesHydrationByReflection()
{
$strategy = new ReflectionHydrationStrategy();

$model = new ModelWithProtectedAttributes();

$strategy->set($model, 'value', 'Test value');
$strategy->set($model, 'separated_value', 'Another test value');

$this->assertEquals($model->getValue(), 'Test value');
$this->assertEquals($model->getSeparatedValue(), 'Another test value');
}
}

class ModelWithPublicAttributes
{
public $value;
public $separated_value;
}

class ModelWithProtectedAttributes
{
protected $value;
protected $separated_value;

public function getValue()
{
return $this->value;
}

public function getSeparatedValue()
{
return $this->separated_value;
}
}

class ModelWithPublicSetters extends ModelWithProtectedAttributes
{
public function setValue($value)
{
$this->value = $value;
}

public function setSeparatedValue($separated_value)
{
$this->separated_value = $separated_value;
}
}

class FakerHydrationModel
{
public $title;
public $email;
public $text;
}
10 changes: 10 additions & 0 deletions tests/factories/hydrationstrategy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

use League\FactoryMuffin\Faker\Facade as Faker;

/* @var \League\FactoryMuffin\FactoryMuffin $fm */
$fm->define('FakerHydrationModel')->setDefinitions([
'title' => Faker::word(),
'email' => Faker::email(),
'text' => Faker::text(),
]);

0 comments on commit f437455

Please sign in to comment.