Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hydration strategies #451

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ You can also define multiple different model definitions for your models. You ca

We have provided a nifty way for you to do this in your tests. PHPUnit provides a `setupBeforeClass` function. Within that function you can call `$fm->loadFactories(__DIR__ . '/factories');`, and it will include all files in the factories folder. Within those php files, you can put your definitions (all your code that calls the define function). The `loadFactories` function will throw a `League\FactoryMuffin\Exceptions\DirectoryNotFoundException` exception if the directory you're loading 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
70 changes: 57 additions & 13 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 All @@ -83,7 +100,7 @@ public function seed($times, $name, array $attr = [])
{
$seeds = [];

for ($i = 0; $i < $times; ++$i) {
for ($i = 0; $i < $times; $i++) {
$seeds[] = $this->create($name, $attr);
}

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(),
]);