diff --git a/README.md b/README.md index b858834..765f383 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/FactoryMuffin.php b/src/FactoryMuffin.php index aeed6ad..29095cc 100644 --- a/src/FactoryMuffin.php +++ b/src/FactoryMuffin.php @@ -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; @@ -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(); } /** @@ -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); } @@ -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; } /** diff --git a/src/HydrationStrategies/HydrationStrategyInterface.php b/src/HydrationStrategies/HydrationStrategyInterface.php new file mode 100644 index 0000000..cdd3818 --- /dev/null +++ b/src/HydrationStrategies/HydrationStrategyInterface.php @@ -0,0 +1,21 @@ + + */ +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); +} diff --git a/src/HydrationStrategies/PublicSetterHydrationStrategy.php b/src/HydrationStrategies/PublicSetterHydrationStrategy.php new file mode 100644 index 0000000..1701b59 --- /dev/null +++ b/src/HydrationStrategies/PublicSetterHydrationStrategy.php @@ -0,0 +1,51 @@ + + */ +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); + } +} diff --git a/src/HydrationStrategies/ReflectionHydrationStrategy.php b/src/HydrationStrategies/ReflectionHydrationStrategy.php new file mode 100644 index 0000000..0d80360 --- /dev/null +++ b/src/HydrationStrategies/ReflectionHydrationStrategy.php @@ -0,0 +1,24 @@ + + */ +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); + } +} diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index 3089e86..8fd075e 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -21,6 +21,9 @@ */ abstract class AbstractTestCase extends PHPUnit_Framework_TestCase { + /** + * @var FactoryMuffin + */ protected static $fm; public static function setupBeforeClass() diff --git a/tests/DefinitionTest.php b/tests/DefinitionTest.php index 8519371..8739607 100644 --- a/tests/DefinitionTest.php +++ b/tests/DefinitionTest.php @@ -38,7 +38,7 @@ public function testGetDefinitions() { $definitions = static::$fm->getDefinitions(); - $this->assertCount(39, $definitions); + $this->assertCount(40, $definitions); } public function testBasicDefinitionFunctions() diff --git a/tests/HydrationStrategyTest.php b/tests/HydrationStrategyTest.php new file mode 100644 index 0000000..c025d6f --- /dev/null +++ b/tests/HydrationStrategyTest.php @@ -0,0 +1,100 @@ +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; +} diff --git a/tests/factories/hydrationstrategy.php b/tests/factories/hydrationstrategy.php new file mode 100644 index 0000000..9193286 --- /dev/null +++ b/tests/factories/hydrationstrategy.php @@ -0,0 +1,10 @@ +define('FakerHydrationModel')->setDefinitions([ + 'title' => Faker::word(), + 'email' => Faker::email(), + 'text' => Faker::text(), +]);