From ad14b2a1f538b8d9446b6fd42a25643cbc59aa2a Mon Sep 17 00:00:00 2001 From: Juan Pablo Ramirez Date: Fri, 18 Dec 2020 01:13:41 +0100 Subject: [PATCH 1/9] Abstract classes, traits and interfaces ignored --- src/Shell/Task/FixtureFactoryTask.php | 42 +++++++++++++++++-- .../src/Model/Table/AbstractPluginTable.php | 19 +++++++++ .../src/Model/Table/AbstractAppTable.php | 19 +++++++++ .../Shell/Task/FixtureFactoryTaskTest.php | 30 ++++++++++++- 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/TestApp/plugins/TestPlugin/src/Model/Table/AbstractPluginTable.php create mode 100644 tests/TestApp/src/Model/Table/AbstractAppTable.php diff --git a/src/Shell/Task/FixtureFactoryTask.php b/src/Shell/Task/FixtureFactoryTask.php index c06aee2b..ee5f9c1a 100644 --- a/src/Shell/Task/FixtureFactoryTask.php +++ b/src/Shell/Task/FixtureFactoryTask.php @@ -15,17 +15,16 @@ use Bake\Shell\Task\SimpleBakeTask; use Cake\Console\ConsoleOptionParser; +use Cake\Core\Configure; use Cake\Filesystem\Folder; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; use CakephpFixtureFactories\Util; +use ReflectionClass; /** * FixtureFactory code generator. - * - * @property \Bake\Shell\Task\BakeTemplateTask $BakeTemplate - * @property \Bake\Shell\Task\TestTask $Test */ class FixtureFactoryTask extends SimpleBakeTask { @@ -128,9 +127,44 @@ public function getTableList(): array { $dir = new Folder($this->getModelPath()); $tables = $dir->find('.*Table.php', true); - return array_map(function ($a) { + + $tables = array_map(function ($a) { return preg_replace('/Table.php$/', '', $a); }, $tables); + + foreach ($tables as $i => $table) { + if (!$this->thisTableShouldBeBaked($table)) { + unset($tables[$i]); + echo "{$table} ignored"; + } + } + + return $tables; + } + + /** + * Return false if the table is not found or is abstract, interface or trait + * @param string $table + * @return bool + */ + public function thisTableShouldBeBaked(string $table): bool + { + + $tableClassName = $this->plugin ? $this->plugin : Configure::read('App.namespace'); + $tableClassName .= "\Model\Table\\{$table}Table"; + + try { + $class = new ReflectionClass($tableClassName); + } catch (\ReflectionException $e) { + echo $e->getMessage(); + return false; + } + + if ($class->isAbstract() || $class->isInterface() || $class->isTrait()) { + return false; + } + + return true; } /** diff --git a/tests/TestApp/plugins/TestPlugin/src/Model/Table/AbstractPluginTable.php b/tests/TestApp/plugins/TestPlugin/src/Model/Table/AbstractPluginTable.php new file mode 100644 index 00000000..86fdaffd --- /dev/null +++ b/tests/TestApp/plugins/TestPlugin/src/Model/Table/AbstractPluginTable.php @@ -0,0 +1,19 @@ +assertEquals($this->appTables, $this->FactoryTask->getTableList()); + $this->assertEquals($this->appTables, array_values($this->FactoryTask->getTableList())); } public function testGetTableListInPlugin() { $this->FactoryTask->plugin = $this->testPluginName; - $this->assertEquals($this->pluginTables, $this->FactoryTask->getTableList()); + $this->assertEquals($this->pluginTables, array_values($this->FactoryTask->getTableList())); } public function testHandleAssociationsWithArticles() @@ -183,6 +183,32 @@ public function testRunBakeWithWrongModel() $this->assertEquals(1, $this->FactoryTask->main('SomeModel')); } + public function dataForTestThisTableShouldBeBaked() + { + return [ + ['Cities', null, true], + ['Cities', true, false], + ['Cities', 'TestPlugin', false], + ['Bills', null, false], + ['Bills', 'TestPlugin', true], + ['AbstractApp', null, false], + ['AbstractPlugin', 'TestPlugin', false], + ]; + } + + /** + * @dataProvider dataForTestThisTableShouldBeBaked + * @param string $model + * @param $plugin + * @param bool $expected + */ + public function testThisTableShouldBeBaked(string $model, $plugin, bool $expected) + { + $this->FactoryTask->plugin = $plugin; + + $this->assertSame($expected, $this->FactoryTask->thisTableShouldBeBaked($model)); + } + // public function testRunBakeWithModel() // { // $this->assertEquals(1, $this->FactoryTask->main('Articles')); From eb99528530506d7ecb04ba26751b4d3d21b84194 Mon Sep 17 00:00:00 2001 From: Juan Pablo Ramirez Date: Wed, 13 Jan 2021 00:02:18 +0100 Subject: [PATCH 2/9] Issue 35 Sets PHP minimal requirement to 7.1 and updates workflows --- .github/workflows/phpstan.yml | 2 +- .github/workflows/test_composer1.yml | 72 --------------------------- .github/workflows/tests_composer2.yml | 13 ++--- composer.json | 11 ++-- 4 files changed, 11 insertions(+), 87 deletions(-) delete mode 100644 .github/workflows/test_composer1.yml diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 99b95fac..8fb71746 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -42,4 +42,4 @@ jobs: run: composer require phpstan/phpstan "^0.12.48@dev" - name: Run phpstan - run: composer run-phpstan + run: composer phpstan diff --git a/.github/workflows/test_composer1.yml b/.github/workflows/test_composer1.yml deleted file mode 100644 index a793b6f4..00000000 --- a/.github/workflows/test_composer1.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Tests on Composer 1 - -on: - push: - branches: - - '*' - pull_request: - branches: - - '*' - schedule: - - cron: '0 6 * * *' - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - php-version: ['7.0', '7.4'] - db-type: [sqlite, mysql, pgsql] - composer-type: [lowest, stable, dev] - exclude: - # excludes composer lowest on mysql - - db-type: mysql - composer-type: lowest - - name: PHP ${{ matrix.php-version }} & ${{ matrix.db-type }} & ${{ matrix.composer-type }} - - services: - postgres: - image: postgres - ports: - - 5432:5432 - env: - POSTGRES_DB: test_fixture_factories - POSTGRES_PASSWORD: root - POSTGRES_USER: root - - steps: - - uses: actions/checkout@v2 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: mbstring, intl, apcu, pdo_${{ matrix.db-type }} - ini-values: apc.enable_cli = 1 - coverage: pcov - - - name: Update composer - run: composer self-update --1 - - - name: Validate composer.json - run: composer validate - - - name: Install dependencies - run: | - if [[ ${{ matrix.composer-type }} == 'lowest' ]]; then - composer update --prefer-dist --no-progress --no-suggest --prefer-stable --prefer-lowest - elif [[ ${{ matrix.composer-type }} == 'stable' ]]; then - composer update --prefer-dist --no-progress --no-suggest --prefer-stable - else - composer update --prefer-dist --no-progress --no-suggest - fi - - - name: Run tests - run: | - if [ ${{ matrix.db-type }} == 'mysql' ]; then - sudo service mysql start && mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE IF NOT EXISTS test_fixture_factories;'; - fi - composer run-tests-${{ matrix.db-type }} \ No newline at end of file diff --git a/.github/workflows/tests_composer2.yml b/.github/workflows/tests_composer2.yml index 90b87218..013008ea 100644 --- a/.github/workflows/tests_composer2.yml +++ b/.github/workflows/tests_composer2.yml @@ -1,4 +1,4 @@ -name: Tests +name: PHPUnit Tests on: push: @@ -17,12 +17,9 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.0', '7.4'] + php-version: ['7.1', '7.4'] db-type: [sqlite, mysql, pgsql] - composer-type: [stable, dev] - exclude: - - php-version: '7.0' - db-type: sqlite + composer-type: [lowest, stable, dev] name: PHP ${{ matrix.php-version }} & ${{ matrix.db-type }} & ${{ matrix.composer-type }} @@ -55,7 +52,7 @@ jobs: - name: Install dependencies run: | if [[ ${{ matrix.composer-type }} == 'lowest' ]]; then - composer update --prefer-dist --no-progress --no-suggest --prefer-stable --prefer-lowest + composer self-update --1 && composer update --prefer-dist --no-progress --no-suggest --prefer-stable --prefer-lowest elif [[ ${{ matrix.composer-type }} == 'stable' ]]; then composer update --prefer-dist --no-progress --no-suggest --prefer-stable else @@ -67,4 +64,4 @@ jobs: if [ ${{ matrix.db-type }} == 'mysql' ]; then sudo service mysql start && mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE IF NOT EXISTS test_fixture_factories;'; fi - composer run-tests-${{ matrix.db-type }} \ No newline at end of file + composer ${{ matrix.db-type }} \ No newline at end of file diff --git a/composer.json b/composer.json index 4437f223..acdc85c2 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "license": "MIT", "minimum-stability": "dev", "require": { - "php": ">=7.0.0", + "php": ">=7.1.0", "cakephp/cakephp": "^3.7", "fzaninotto/faker": "^1.9@dev", "vierge-noire/cakephp-test-suite-light": "^1.1" @@ -26,7 +26,6 @@ "josegonzalez/dotenv": "dev-master", "phpunit/phpunit": "^6.1", "vierge-noire/cakephp-test-migrator": "^1.1" - }, "autoload": { "psr-4": { @@ -43,10 +42,10 @@ } }, "scripts": { - "run-tests-mysql": "bash run_tests.sh Mysql", - "run-tests-pgsql": "bash run_tests.sh Postgres", - "run-tests-sqlite": "bash run_tests.sh Sqlite", - "run-phpstan": "vendor/bin/phpstan analyse" + "mysql": "bash run_tests.sh Mysql", + "pgsql": "bash run_tests.sh Postgres", + "sqlite": "bash run_tests.sh Sqlite", + "phpstan": "vendor/bin/phpstan analyse" }, "config": { "sort-packages": true From 4c1fb788c387c6524a466313b6701bbf008ad09f Mon Sep 17 00:00:00 2001 From: Juan Pablo Ramirez Date: Thu, 14 Jan 2021 14:32:06 +0100 Subject: [PATCH 3/9] Implements issue #40 --- src/Factory/DataCompiler.php | 924 +++++++++--------- tests/Factory/ArticleFactory.php | 10 +- .../20200208100000_initial_migration.php | 4 + tests/TestApp/src/Model/Entity/Article.php | 6 + .../BaseFactoryHiddenPropertiesTest.php | 129 +++ 5 files changed, 610 insertions(+), 463 deletions(-) create mode 100644 tests/TestCase/Factory/BaseFactoryHiddenPropertiesTest.php diff --git a/src/Factory/DataCompiler.php b/src/Factory/DataCompiler.php index 7ae9aa4e..cb9444e6 100644 --- a/src/Factory/DataCompiler.php +++ b/src/Factory/DataCompiler.php @@ -1,462 +1,462 @@ -factory = $factory; - } - - /** - * Data passed in the instantiation by array - * @param array $data - */ - public function collectFromArray(array $data) - { - $this->dataFromInstantiation = $data; - } - - /** - * Data passed in the instantiation by callable - * @param callable $fn - */ - public function collectArrayFromCallable(callable $fn) - { - // if the callable returns an array, add it the the templateData array, so it will be compiled - $returnValue = $fn($this->getFactory(), $this->getFactory()->getFaker()); - if (is_array($returnValue)) { - $this->dataFromInstantiation = $fn; - } - } - - /** - * @param array $data - */ - public function collectFromPatch(array $data) - { - $this->dataFromPatch = array_merge($this->dataFromPatch, $data); - } - - /** - * @param callable $fn - */ - public function collectFromDefaultTemplate(callable $fn) - { - $this->dataFromDefaultTemplate = $fn; - } - - /** - * @param string $associationName - * @param BaseFactory $factory - */ - public function collectAssociation(string $associationName, BaseFactory $factory) - { - if (isset($this->dataFromAssociations[$associationName])) { - $this->dataFromAssociations[$associationName][] = $factory; - } else { - $this->dataFromAssociations[$associationName] = [$factory]; - } - } - - /** - * Scan for the data stored in the $association path provided and drop it - * @param string $associationName - * @return void - */ - public function dropAssociation(string $associationName) - { - unset($this->dataFromAssociations[$associationName]); - unset($this->dataFromDefaultAssociations[$associationName]); - } - - /** - * Populate the factored entity - * @return array - */ - public function getCompiledTemplateData(): array - { - if (is_array($this->dataFromInstantiation) && isset($this->dataFromInstantiation[0])) { - $compiledTemplateData = []; - foreach ($this->dataFromInstantiation as $entity) { - $compiledTemplateData[] = $this->compileEntity($entity); - } - } else { - $compiledTemplateData = $this->compileEntity($this->dataFromInstantiation); - } - - return $compiledTemplateData; - } - - /** - * @param array|callable $injectedData - * - * @return array - */ - public function compileEntity($injectedData): array - { - $entity = []; - // This order is very important!!! - $this - ->mergeWithDefaultTemplate($entity) - ->mergeWithInjectedData($entity, $injectedData) - ->mergeWithPatchedData($entity) - ->mergeWithAssociatedData($entity); - - return $this->setPrimaryKey($entity); - } - - /** - * Step 1: merge the default template data - * @param array $compiledTemplateData - * @return $this - */ - private function mergeWithDefaultTemplate(array &$compiledTemplateData): self - { - if (!empty($compiledTemplateData)) { - throw new FixtureFactoryException('The initial array before merging with the default template should be empty'); - } - $data = $this->dataFromDefaultTemplate; - if (is_array($data)) { - $compiledTemplateData = array_merge($compiledTemplateData, $data); - } elseif (is_callable($data)) { - $compiledTemplateData = array_merge($compiledTemplateData, $data($this->getFactory()->getFaker())); - } - return $this; - } - - /** - * Step 2: - * Merge with the data injected during the instantiation of the Factory - * @param array $compiledTemplateData - * @param array|callable $injectedData - * @return $this - */ - private function mergeWithInjectedData(array &$compiledTemplateData, $injectedData): self - { - if (is_callable($injectedData)) { - $array = $injectedData( - $this->getFactory(), - $this->getFactory()->getFaker() - ); - $compiledTemplateData = array_merge($compiledTemplateData, $array); - } elseif (is_array($injectedData)) { - $compiledTemplateData = array_merge($compiledTemplateData, $injectedData); - } - return $this; - } - - /** - * Step 3: - * Merge with the data gathered by patching - * Do not return this, as this is the last step - * @param array $compiledTemplateData - */ - private function mergeWithPatchedData(array &$compiledTemplateData) - { - $compiledTemplateData = array_merge($compiledTemplateData, $this->dataFromPatch); - return $this; - } - - /** - * Step 4: - * Merge with the data from the associations - * @param array $compiledTemplateData - */ - private function mergeWithAssociatedData(array &$compiledTemplateData): self - { - // Overwrite the default associations if these are found in the associations - $associatedData = array_merge($this->dataFromDefaultAssociations, $this->dataFromAssociations); - - foreach ($associatedData as $propertyName => $data) { - $association = $this->getAssociationByPropertyName($propertyName); - $propertyName = $this->getMarshallerAssociationName($propertyName); - if ($association instanceof HasOne || $association instanceof BelongsTo) { - // toOne associated data must be singular when saved - $this->mergeWithToOne($compiledTemplateData, $propertyName, $data); - } else { - $this->mergeWithToMany($compiledTemplateData, $propertyName, $data); - } - } - return $this; - } - - /** - * There might be several data feeding a toOne relation - * One reason can be the default template value. - * Here the latest inserted record is taken - * - * @param array $compiledTemplateData - * @param string $associationName - * @param array $data - */ - private function mergeWithToOne(array &$compiledTemplateData, string $associationName, array $data) - { - $count = count($data); - $associationName = Inflector::singularize($associationName); - /** @var BaseFactory $factory */ - $factory = $data[$count - 1]; - $compiledTemplateData[$associationName] = $factory->getEntity()->toArray(); - } - - /** - * @param array $compiledTemplateData - * @param string $associationName - * @param array $data - */ - private function mergeWithToMany(array &$compiledTemplateData, string $associationName, array $data) - { - $associationData = $compiledTemplateData[$associationName] ?? null; - foreach ($data as $factory) { - if ($associationData) { - $associationData = array_merge($associationData, $this->getManyEntities($factory)); - } else { - $associationData = $this->getManyEntities($factory); - } - } - $compiledTemplateData[$associationName] = $associationData; - } - - /** - * @param BaseFactory $factory - * - * @return array - */ - private function getManyEntities(BaseFactory $factory): array - { - $result = []; - foreach ($factory->getEntities() as $entity) { - $result[] = $entity->toArray(); - } - return $result; - } - - /** - * Used in the Factory make in order to distinguish default associations - * from conscious associations - */ - public function collectAssociationsFromDefaultTemplate() - { - $this->dataFromDefaultAssociations = $this->dataFromAssociations; - $this->dataFromAssociations = []; - } - - /** - * Returns the property name of the association. This can be dot separated for deep associations - * Throws an exception if the association name does not exist on the rootTable of the factory - * @param string $associationName - * @return string underscore_version of the input string - * @throws \InvalidArgumentException - */ - public function getMarshallerAssociationName(string $associationName): string - { - $result = []; - $cast = explode('.', $associationName); - $table = $this->getFactory()->getRootTableRegistry(); - foreach ($cast as $i => $ass) { - $association = $table->getAssociation($ass); - $result[] = $association->getProperty(); - $table = $association->getTarget(); - } - return implode('.', $result); - } - - /** - * @param string $propertyName - * @return bool|Association - */ - public function getAssociationByPropertyName(string $propertyName) - { - try { - return $this->getFactory()->getRootTableRegistry()->getAssociation(Inflector::camelize($propertyName)); - } catch (InvalidArgumentException $e) { - return false; - } - } - - /** - * @param array $data - * - * @return array - */ - public function setPrimaryKey(array $data): array - { - // A set of primary keys is produced if in persistence mode, and if a first set was not produced yet - if (!$this->isInPersistMode() || !is_array($this->primaryKeyOffset)) { - return $data; - } - - // If we have an array of multiple entities, set only for the first one - if (isset($data[0])) { - $data[0] = $this->setPrimaryKey($data[0]); - } else { - $data = array_merge( - $this->createPrimaryKeyOffset(), - $data - ); - } - return $data; - } - - /** - * - * @return array - */ - public function createPrimaryKeyOffset(): array - { - if (!is_array($this->primaryKeyOffset)) { - throw new PersistenceException('A set of primary keys was already created'); - } - $res = empty($this->primaryKeyOffset) ? $this->generateArrayOfRandomPrimaryKeys() : $this->primaryKeyOffset; - - $this->updatePostgresSequence($res); - - // Set to null, this factory will never generate a primaryKeyOffset again - $this->primaryKeyOffset = null; - return $res; - } - - /** - * Get the primary key, or set of composite primary keys - * @return string|string[] - */ - public function getRootTablePrimaryKey() - { - return $this->getFactory()->getRootTableRegistry()->getPrimaryKey(); - } - - public function generateArrayOfRandomPrimaryKeys(): array - { - $primaryKeys = (array) $this->getRootTablePrimaryKey(); - $res = []; - foreach ($primaryKeys as $pk) { - $res[$pk] = $this->generateRandomPrimaryKey( - $this->getFactory()->getRootTableRegistry()->getSchema()->getColumnType($pk) - ); - } - return $res; - } - - /** - * Credits to Faker - * https://github.com/fzaninotto/Faker/blob/master/src/Faker/ORM/CakePHP/ColumnTypeGuesser.php - * - * @param string $columnType - * @return int|string - */ - public function generateRandomPrimaryKey(string $columnType) - { - switch ($columnType) { - case 'uuid': - $res = $this->getFactory()->getFaker()->uuid; - break; - case 'biginteger': - $res = mt_rand(0, intval('9223372036854775807')); - break; - case 'integer': - default: - $res = mt_rand(0, intval('2147483647')); - break; - } - return $res; - } - - /** - * @return BaseFactory - */ - public function getFactory(): BaseFactory - { - return $this->factory; - } - - /** - * @param int|string|array $primaryKeyOffset - */ - public function setPrimaryKeyOffset($primaryKeyOffset) - { - if (is_int($primaryKeyOffset) || is_string($primaryKeyOffset)) { - $this->primaryKeyOffset = [ - $this->getRootTablePrimaryKey() => $primaryKeyOffset - ]; - } elseif (is_array($primaryKeyOffset)) { - $this->primaryKeyOffset = $primaryKeyOffset; - } else { - throw new FixtureFactoryException("$primaryKeyOffset must be either an integer, a string or an array of format ['primaryKey1' => value, ...]"); - } - } - - /** - * @param array $primaryKeys - * @return void - */ - private function updatePostgresSequence(array $primaryKeys) - { - if (Util::isRunningOnPostgresql($this->getFactory())) { - $tableName = $this->getFactory()->getRootTableRegistry()->getTable(); - - foreach ($primaryKeys as $pk => $offset) { - $this->getFactory()->getRootTableRegistry()->getConnection()->execute( - "SELECT setval('$tableName". "_$pk" . "_seq', $offset);" - ); - } - } - } - - /** - * @return bool - */ - public function isInPersistMode(): bool - { - return self::$inPersistMode; - } - - public function startPersistMode() - { - self::$inPersistMode = true; - } - - public function endPersistMode() - { - self::$inPersistMode = false; - } -} +factory = $factory; + } + + /** + * Data passed in the instantiation by array + * @param array $data + */ + public function collectFromArray(array $data) + { + $this->dataFromInstantiation = $data; + } + + /** + * Data passed in the instantiation by callable + * @param callable $fn + */ + public function collectArrayFromCallable(callable $fn) + { + // if the callable returns an array, add it the the templateData array, so it will be compiled + $returnValue = $fn($this->getFactory(), $this->getFactory()->getFaker()); + if (is_array($returnValue)) { + $this->dataFromInstantiation = $fn; + } + } + + /** + * @param array $data + */ + public function collectFromPatch(array $data) + { + $this->dataFromPatch = array_merge($this->dataFromPatch, $data); + } + + /** + * @param callable $fn + */ + public function collectFromDefaultTemplate(callable $fn) + { + $this->dataFromDefaultTemplate = $fn; + } + + /** + * @param string $associationName + * @param BaseFactory $factory + */ + public function collectAssociation(string $associationName, BaseFactory $factory) + { + if (isset($this->dataFromAssociations[$associationName])) { + $this->dataFromAssociations[$associationName][] = $factory; + } else { + $this->dataFromAssociations[$associationName] = [$factory]; + } + } + + /** + * Scan for the data stored in the $association path provided and drop it + * @param string $associationName + * @return void + */ + public function dropAssociation(string $associationName) + { + unset($this->dataFromAssociations[$associationName]); + unset($this->dataFromDefaultAssociations[$associationName]); + } + + /** + * Populate the factored entity + * @return array + */ + public function getCompiledTemplateData(): array + { + if (is_array($this->dataFromInstantiation) && isset($this->dataFromInstantiation[0])) { + $compiledTemplateData = []; + foreach ($this->dataFromInstantiation as $entity) { + $compiledTemplateData[] = $this->compileEntity($entity); + } + } else { + $compiledTemplateData = $this->compileEntity($this->dataFromInstantiation); + } + + return $compiledTemplateData; + } + + /** + * @param array|callable $injectedData + * + * @return array + */ + public function compileEntity($injectedData): array + { + $entity = []; + // This order is very important!!! + $this + ->mergeWithDefaultTemplate($entity) + ->mergeWithInjectedData($entity, $injectedData) + ->mergeWithPatchedData($entity) + ->mergeWithAssociatedData($entity); + + return $this->setPrimaryKey($entity); + } + + /** + * Step 1: merge the default template data + * @param array $compiledTemplateData + * @return $this + */ + private function mergeWithDefaultTemplate(array &$compiledTemplateData): self + { + if (!empty($compiledTemplateData)) { + throw new FixtureFactoryException('The initial array before merging with the default template should be empty'); + } + $data = $this->dataFromDefaultTemplate; + if (is_array($data)) { + $compiledTemplateData = array_merge($compiledTemplateData, $data); + } elseif (is_callable($data)) { + $compiledTemplateData = array_merge($compiledTemplateData, $data($this->getFactory()->getFaker())); + } + return $this; + } + + /** + * Step 2: + * Merge with the data injected during the instantiation of the Factory + * @param array $compiledTemplateData + * @param array|callable $injectedData + * @return $this + */ + private function mergeWithInjectedData(array &$compiledTemplateData, $injectedData): self + { + if (is_callable($injectedData)) { + $array = $injectedData( + $this->getFactory(), + $this->getFactory()->getFaker() + ); + $compiledTemplateData = array_merge($compiledTemplateData, $array); + } elseif (is_array($injectedData)) { + $compiledTemplateData = array_merge($compiledTemplateData, $injectedData); + } + return $this; + } + + /** + * Step 3: + * Merge with the data gathered by patching + * Do not return this, as this is the last step + * @param array $compiledTemplateData + */ + private function mergeWithPatchedData(array &$compiledTemplateData) + { + $compiledTemplateData = array_merge($compiledTemplateData, $this->dataFromPatch); + return $this; + } + + /** + * Step 4: + * Merge with the data from the associations + * @param array $compiledTemplateData + */ + private function mergeWithAssociatedData(array &$compiledTemplateData): self + { + // Overwrite the default associations if these are found in the associations + $associatedData = array_merge($this->dataFromDefaultAssociations, $this->dataFromAssociations); + + foreach ($associatedData as $propertyName => $data) { + $association = $this->getAssociationByPropertyName($propertyName); + $propertyName = $this->getMarshallerAssociationName($propertyName); + if ($association instanceof HasOne || $association instanceof BelongsTo) { + // toOne associated data must be singular when saved + $this->mergeWithToOne($compiledTemplateData, $propertyName, $data); + } else { + $this->mergeWithToMany($compiledTemplateData, $propertyName, $data); + } + } + return $this; + } + + /** + * There might be several data feeding a toOne relation + * One reason can be the default template value. + * Here the latest inserted record is taken + * + * @param array $compiledTemplateData + * @param string $associationName + * @param array $data + */ + private function mergeWithToOne(array &$compiledTemplateData, string $associationName, array $data) + { + $count = count($data); + $associationName = Inflector::singularize($associationName); + /** @var BaseFactory $factory */ + $factory = $data[$count - 1]; + $compiledTemplateData[$associationName] = $factory->getEntity()->setHidden([])->toArray(); + } + + /** + * @param array $compiledTemplateData + * @param string $associationName + * @param array $data + */ + private function mergeWithToMany(array &$compiledTemplateData, string $associationName, array $data) + { + $associationData = $compiledTemplateData[$associationName] ?? null; + foreach ($data as $factory) { + if ($associationData) { + $associationData = array_merge($associationData, $this->getManyEntities($factory)); + } else { + $associationData = $this->getManyEntities($factory); + } + } + $compiledTemplateData[$associationName] = $associationData; + } + + /** + * @param BaseFactory $factory + * + * @return array + */ + private function getManyEntities(BaseFactory $factory): array + { + $result = []; + foreach ($factory->getEntities() as $entity) { + $result[] = $entity->setHidden([])->toArray(); + } + return $result; + } + + /** + * Used in the Factory make in order to distinguish default associations + * from conscious associations + */ + public function collectAssociationsFromDefaultTemplate() + { + $this->dataFromDefaultAssociations = $this->dataFromAssociations; + $this->dataFromAssociations = []; + } + + /** + * Returns the property name of the association. This can be dot separated for deep associations + * Throws an exception if the association name does not exist on the rootTable of the factory + * @param string $associationName + * @return string underscore_version of the input string + * @throws \InvalidArgumentException + */ + public function getMarshallerAssociationName(string $associationName): string + { + $result = []; + $cast = explode('.', $associationName); + $table = $this->getFactory()->getRootTableRegistry(); + foreach ($cast as $i => $ass) { + $association = $table->getAssociation($ass); + $result[] = $association->getProperty(); + $table = $association->getTarget(); + } + return implode('.', $result); + } + + /** + * @param string $propertyName + * @return bool|Association + */ + public function getAssociationByPropertyName(string $propertyName) + { + try { + return $this->getFactory()->getRootTableRegistry()->getAssociation(Inflector::camelize($propertyName)); + } catch (InvalidArgumentException $e) { + return false; + } + } + + /** + * @param array $data + * + * @return array + */ + public function setPrimaryKey(array $data): array + { + // A set of primary keys is produced if in persistence mode, and if a first set was not produced yet + if (!$this->isInPersistMode() || !is_array($this->primaryKeyOffset)) { + return $data; + } + + // If we have an array of multiple entities, set only for the first one + if (isset($data[0])) { + $data[0] = $this->setPrimaryKey($data[0]); + } else { + $data = array_merge( + $this->createPrimaryKeyOffset(), + $data + ); + } + return $data; + } + + /** + * + * @return array + */ + public function createPrimaryKeyOffset(): array + { + if (!is_array($this->primaryKeyOffset)) { + throw new PersistenceException('A set of primary keys was already created'); + } + $res = empty($this->primaryKeyOffset) ? $this->generateArrayOfRandomPrimaryKeys() : $this->primaryKeyOffset; + + $this->updatePostgresSequence($res); + + // Set to null, this factory will never generate a primaryKeyOffset again + $this->primaryKeyOffset = null; + return $res; + } + + /** + * Get the primary key, or set of composite primary keys + * @return string|string[] + */ + public function getRootTablePrimaryKey() + { + return $this->getFactory()->getRootTableRegistry()->getPrimaryKey(); + } + + public function generateArrayOfRandomPrimaryKeys(): array + { + $primaryKeys = (array) $this->getRootTablePrimaryKey(); + $res = []; + foreach ($primaryKeys as $pk) { + $res[$pk] = $this->generateRandomPrimaryKey( + $this->getFactory()->getRootTableRegistry()->getSchema()->getColumnType($pk) + ); + } + return $res; + } + + /** + * Credits to Faker + * https://github.com/fzaninotto/Faker/blob/master/src/Faker/ORM/CakePHP/ColumnTypeGuesser.php + * + * @param string $columnType + * @return int|string + */ + public function generateRandomPrimaryKey(string $columnType) + { + switch ($columnType) { + case 'uuid': + $res = $this->getFactory()->getFaker()->uuid; + break; + case 'biginteger': + $res = mt_rand(0, intval('9223372036854775807')); + break; + case 'integer': + default: + $res = mt_rand(0, intval('2147483647')); + break; + } + return $res; + } + + /** + * @return BaseFactory + */ + public function getFactory(): BaseFactory + { + return $this->factory; + } + + /** + * @param int|string|array $primaryKeyOffset + */ + public function setPrimaryKeyOffset($primaryKeyOffset) + { + if (is_int($primaryKeyOffset) || is_string($primaryKeyOffset)) { + $this->primaryKeyOffset = [ + $this->getRootTablePrimaryKey() => $primaryKeyOffset + ]; + } elseif (is_array($primaryKeyOffset)) { + $this->primaryKeyOffset = $primaryKeyOffset; + } else { + throw new FixtureFactoryException("$primaryKeyOffset must be either an integer, a string or an array of format ['primaryKey1' => value, ...]"); + } + } + + /** + * @param array $primaryKeys + * @return void + */ + private function updatePostgresSequence(array $primaryKeys) + { + if (Util::isRunningOnPostgresql($this->getFactory())) { + $tableName = $this->getFactory()->getRootTableRegistry()->getTable(); + + foreach ($primaryKeys as $pk => $offset) { + $this->getFactory()->getRootTableRegistry()->getConnection()->execute( + "SELECT setval('$tableName". "_$pk" . "_seq', $offset);" + ); + } + } + } + + /** + * @return bool + */ + public function isInPersistMode(): bool + { + return self::$inPersistMode; + } + + public function startPersistMode() + { + self::$inPersistMode = true; + } + + public function endPersistMode() + { + self::$inPersistMode = false; + } +} diff --git a/tests/Factory/ArticleFactory.php b/tests/Factory/ArticleFactory.php index dd0b2924..a0811a41 100644 --- a/tests/Factory/ArticleFactory.php +++ b/tests/Factory/ArticleFactory.php @@ -13,8 +13,9 @@ */ namespace CakephpFixtureFactories\Test\Factory; -use Faker\Generator; use CakephpFixtureFactories\Factory\BaseFactory; +use Faker\Generator; +use TestApp\Model\Entity\Article; class ArticleFactory extends BaseFactory { @@ -94,4 +95,11 @@ public function setJobTitle() 'title' => $this->getFaker()->jobTitle, ]); } + + public function withHiddenBiography(string $text) + { + return $this->patchData([ + Article::HIDDEN_PARAGRAPH_PROPERTY_NAME => $text + ]); + } } diff --git a/tests/TestApp/config/Migrations/20200208100000_initial_migration.php b/tests/TestApp/config/Migrations/20200208100000_initial_migration.php index 1c77ff4b..fa64fe57 100644 --- a/tests/TestApp/config/Migrations/20200208100000_initial_migration.php +++ b/tests/TestApp/config/Migrations/20200208100000_initial_migration.php @@ -52,6 +52,10 @@ public function up() 'limit' => 128, 'null' => true, ]) + ->addColumn(\TestApp\Model\Entity\Article::HIDDEN_PARAGRAPH_PROPERTY_NAME, 'text', [ + 'default' => null, + 'null' => true, + ]) ->addColumn('published', 'integer', [ 'default' => 0, 'null' => false, diff --git a/tests/TestApp/src/Model/Entity/Article.php b/tests/TestApp/src/Model/Entity/Article.php index a3fbec6d..c34fbccc 100644 --- a/tests/TestApp/src/Model/Entity/Article.php +++ b/tests/TestApp/src/Model/Entity/Article.php @@ -20,6 +20,8 @@ */ class Article extends Entity { + public const HIDDEN_PARAGRAPH_PROPERTY_NAME = 'hidden_paragraph'; + /** * Fields that can be mass assigned using newEntity() or patchEntity(). * @@ -38,4 +40,8 @@ class Article extends Entity 'bills' => true, 'authors' => true, ]; + + protected $_hidden = [ + self::HIDDEN_PARAGRAPH_PROPERTY_NAME, + ]; } diff --git a/tests/TestCase/Factory/BaseFactoryHiddenPropertiesTest.php b/tests/TestCase/Factory/BaseFactoryHiddenPropertiesTest.php new file mode 100644 index 00000000..a7e6aefe --- /dev/null +++ b/tests/TestCase/Factory/BaseFactoryHiddenPropertiesTest.php @@ -0,0 +1,129 @@ +assertSame(self::DUMMY_HIDDEN_PARAGRAPH, $article->get(Article::HIDDEN_PARAGRAPH_PROPERTY_NAME)); + if ($persisted) { + $article = TableRegistry::getTableLocator()->get('Articles')->find()->where([ + 'id' => $article->get('id'), + Article::HIDDEN_PARAGRAPH_PROPERTY_NAME => self::DUMMY_HIDDEN_PARAGRAPH, + ])->firstOrFail(); + $this->assertSame(self::DUMMY_HIDDEN_PARAGRAPH, $article->get(Article::HIDDEN_PARAGRAPH_PROPERTY_NAME)); + $this->assertSame(null, $article->toArray()[Article::HIDDEN_PARAGRAPH_PROPERTY_NAME] ?? null); + } + } + } + + public function iterate() + { + return [ + [1, false], + [1, true], + [2, false], + [2, true], + ]; + } + + /** + * @Given a property is hidden + * @When a factory is persisted + * @Then the field is accessible and persisted. + * + * @param int $n + * @param bool $persist + * @throws \Exception + * @dataProvider iterate + */ + public function testHiddenPropertyInMainBuild(int $n, bool $persist) + { + $factory = ArticleFactory::make($n)->withHiddenBiography(self::DUMMY_HIDDEN_PARAGRAPH); + + if ($n > 1) { + $articles = $persist ? $factory->persist() : $factory->getEntities(); + } else { + $articles = $persist ? $factory->persist() : $factory->getEntity(); + } + $this->assertHiddenParagraphIsVisible($articles, $persist); + } + + /** + * @Given a property in a belongs to many association is hidden + * @When a factory is persisted + * @Then the field is accessible and persisted. + * + * @param int $n + * @param bool $persist + * @throws \Exception + * @dataProvider iterate + */ + public function testHiddenPropertyInBelongsToManyAssociation(int $n, bool $persist) + { + $factory = AuthorFactory::make()->with('Articles', + ArticleFactory::make($n)->withHiddenBiography(self::DUMMY_HIDDEN_PARAGRAPH) + ); + + $articles = $persist ? $factory->persist()->get('articles') : $factory->getEntity()->get('articles'); + $this->assertHiddenParagraphIsVisible($articles, $persist); + } + + /** + * @Given a property in a has many association is hidden + * @When a factory is persisted + * @Then the field is accessible and persisted. + * + * @param int $n + * @param bool $persist + * @throws \Exception + * @dataProvider iterate + */ + public function testHiddenPropertyInBelongsToAssociation(int $n, bool $persist) + { + $factory = BillFactory::make($n)->with('Article', + ArticleFactory::make()->withHiddenBiography(self::DUMMY_HIDDEN_PARAGRAPH) + ); + + $bills = $persist ? $factory->persist() : $factory->getEntity(); + + if (is_array($bills)) { + foreach ($bills as $bill) { + $this->assertHiddenParagraphIsVisible($bill->get('article'), $persist); + } + } else { + $this->assertHiddenParagraphIsVisible($bills->get('article'), $persist); + } + } +} \ No newline at end of file From 9e1923563182bbebc7048f60351e1ef42ceee274 Mon Sep 17 00:00:00 2001 From: jpramirez Date: Fri, 22 Jan 2021 20:13:49 +0100 Subject: [PATCH 4/9] Implements issue #36 on cake3 (#44) --- docs/factories.md | 43 ++- src/Error/UniquenessException.php | 20 + src/Event/ModelEventsHandler.php | 1 + src/Factory/BaseFactory.php | 34 ++ src/Factory/DataCompiler.php | 91 ++++- src/Factory/EventCollector.php | 33 +- src/Factory/UniquenessJanitor.php | 97 +++++ src/ORM/FactoryTableBeforeSave.php | 204 ++++++++++ src/ORM/{Locator => }/FactoryTableLocator.php | 14 +- .../FactoryTableRegistry.php | 3 +- tests/Factory/CityFactory.php | 4 + tests/Factory/CountryFactory.php | 5 + .../20200208100000_initial_migration.php | 12 + .../Error/PersistingExceptionTest.php | 39 -- .../Factory/BaseFactoryUniqueEntitiesTest.php | 358 ++++++++++++++++++ tests/TestCase/Factory/DataCompilerTest.php | 28 +- .../Factory/UniquenessJanitorTest.php | 107 ++++++ .../ORM/FactoryTableBeforeSaveTest.php | 79 ++++ .../FactoryTableRegistryTest.php | 4 +- 19 files changed, 1089 insertions(+), 87 deletions(-) create mode 100644 src/Error/UniquenessException.php create mode 100644 src/Factory/UniquenessJanitor.php create mode 100644 src/ORM/FactoryTableBeforeSave.php rename src/ORM/{Locator => }/FactoryTableLocator.php (65%) rename src/ORM/{TableRegistry => }/FactoryTableRegistry.php (94%) create mode 100644 tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php create mode 100644 tests/TestCase/Factory/UniquenessJanitorTest.php create mode 100644 tests/TestCase/ORM/FactoryTableBeforeSaveTest.php rename tests/TestCase/ORM/{TableRegistry => }/FactoryTableRegistryTest.php (95%) diff --git a/docs/factories.md b/docs/factories.md index 464b4071..f1460f3e 100644 --- a/docs/factories.md +++ b/docs/factories.md @@ -90,7 +90,46 @@ $article = ArticleFactory::makeWithModelEvents()->persist(); Or for a plugin Foo, in `Foo\Test\Factory`. You may change that by setting in your configuration the key `TestFixtureNamespace` to the desired namespace. - - ### Next + + ### Property uniqueness + +It is not rare to have to create entities associated with an entity that should remain +constant and should not be recreated once it was already persisted. For example, if you create +5 cities within a country, you will not want to have 5 countries created. This might +collide with the constrains of your schema. The same goes of course with primary keys. + +The fixture factories offer to define unique properties, under the protected property +$uniqueProperties. For example given a country factory. + +```$xslt +namespace App\Test\Factory; +... +class CountryFactory extends BaseFactory +{ + protected $uniqueProperties = [ + 'name', + ]; +... +} +``` + +Knowing the property `name` is unique, the country factory +will be cautious whenever the property `name` is set by the developer. + +Executing `CityFactory::make(5)->with('Country', ['name' => 'Foo'])->persist()` will create +5 cities all associated to one unique country. If you perform that same operation again, +you will have 10 cities, all associated to one single country. + +### Primary keys uniqueness + +The uniqueness of the primary keys is handled exactely the same way as described above, +with the particularity that you do not have to define them as unique. The factory +cannot read the uniqueness of a property in the schema, but it knows which properties +are primary keys. Therefore, executing +`CityFactory::make(5)->with('Country', ['myPrimaryKey' => 1])->persist()` will behave the +same as if the primary key `myPrimaryKey` had been defined unique. In short, the factories +do the job for you. + +### Next Let us now see [how to use them](examples.md)... diff --git a/src/Error/UniquenessException.php b/src/Error/UniquenessException.php new file mode 100644 index 00000000..fac96cfd --- /dev/null +++ b/src/Error/UniquenessException.php @@ -0,0 +1,20 @@ + false, 'checkExisting' => false ]; + /** + * @var array Unique fields. Uniqueness applies only to persisted entities. + */ + protected $uniqueProperties = []; /** * @var bool */ @@ -263,6 +267,7 @@ public function toArray(): array $data[] = $compiledData; } } + UniquenessJanitor::sanitizeEntityArray($this, $data); return $data; } @@ -444,6 +449,35 @@ public function setPrimaryKeyOffset($primaryKeyOffset): self return $this; } + /** + * Get the fields that are declared are unique. + * This should include the uniqueness of the fields in your schema. + * + * @return array + */ + public function getUniqueProperties(): array + { + return $this->uniqueProperties; + } + + /** + * Set the unique fields of the factory. + * If a field is unique and explicitly modified, + * it's existence will be checked + * before persisting. If found, no new + * entity will be created, but instead the + * existing one will be considered. + * + * @param array|string|null $fields Unique fields set on the fly. + * @return $this + */ + public function setUniqueProperties($fields) + { + $this->uniqueProperties = (array)$fields; + + return $this; + } + /** * Populate the entity factored * @param callable $fn diff --git a/src/Factory/DataCompiler.php b/src/Factory/DataCompiler.php index cb9444e6..ecba1bb3 100644 --- a/src/Factory/DataCompiler.php +++ b/src/Factory/DataCompiler.php @@ -25,12 +25,16 @@ class DataCompiler { + public const MODIFIED_UNIQUE_PROPERTIES = '___data_compiler__modified_unique_properties'; + public const IS_ASSOCIATED = '___data_compiler__is_associated'; + private $dataFromDefaultTemplate = []; private $dataFromInstantiation = []; private $dataFromPatch = []; private $dataFromAssociations = []; private $dataFromDefaultAssociations = []; private $primaryKeyOffset = []; + private $enforcedFields = []; static private $inPersistMode = false; @@ -135,15 +139,19 @@ public function getCompiledTemplateData(): array */ public function compileEntity($injectedData): array { - $entity = []; + $entityData = []; // This order is very important!!! $this - ->mergeWithDefaultTemplate($entity) - ->mergeWithInjectedData($entity, $injectedData) - ->mergeWithPatchedData($entity) - ->mergeWithAssociatedData($entity); + ->mergeWithDefaultTemplate($entityData) + ->mergeWithInjectedData($entityData, $injectedData) + ->mergeWithPatchedData($entityData) + ->mergeWithAssociatedData($entityData); + + if ($this->isInPersistMode() && !empty($this->getModifiedUniqueFields())) { + $entityData[self::MODIFIED_UNIQUE_PROPERTIES] = $this->getModifiedUniqueFields(); + } - return $this->setPrimaryKey($entity); + return $this->setPrimaryKey($entityData); } /** @@ -182,19 +190,25 @@ private function mergeWithInjectedData(array &$compiledTemplateData, $injectedDa $compiledTemplateData = array_merge($compiledTemplateData, $array); } elseif (is_array($injectedData)) { $compiledTemplateData = array_merge($compiledTemplateData, $injectedData); + $this->addEnforcedFields($injectedData); } return $this; } /** * Step 3: - * Merge with the data gathered by patching - * Do not return this, as this is the last step + * Merge with the data gathered by patching. + * At this point, the developer all the data + * modified by the user is known ("enforced fields"). + * This will be passed as field to the dedicated table's + * beforeFind in order to handle the uniqueness of its fields. * @param array $compiledTemplateData */ private function mergeWithPatchedData(array &$compiledTemplateData) { $compiledTemplateData = array_merge($compiledTemplateData, $this->dataFromPatch); + $this->addEnforcedFields($this->dataFromPatch); + return $this; } @@ -236,7 +250,8 @@ private function mergeWithToOne(array &$compiledTemplateData, string $associatio $associationName = Inflector::singularize($associationName); /** @var BaseFactory $factory */ $factory = $data[$count - 1]; - $compiledTemplateData[$associationName] = $factory->getEntity()->setHidden([])->toArray(); + $compiledTemplateData[$associationName] = + $factory->getEntity()->setHidden([])->toArray() + ($this->isInPersistMode() ? [self::IS_ASSOCIATED => true] : []); } /** @@ -266,7 +281,7 @@ private function getManyEntities(BaseFactory $factory): array { $result = []; foreach ($factory->getEntities() as $entity) { - $result[] = $entity->setHidden([])->toArray(); + $result[] = $entity->setHidden([])->toArray() + ($this->isInPersistMode() ? [self::IS_ASSOCIATED => true] : []); } return $result; } @@ -357,17 +372,11 @@ public function createPrimaryKeyOffset(): array } /** - * Get the primary key, or set of composite primary keys - * @return string|string[] + * @return array */ - public function getRootTablePrimaryKey() - { - return $this->getFactory()->getRootTableRegistry()->getPrimaryKey(); - } - public function generateArrayOfRandomPrimaryKeys(): array { - $primaryKeys = (array) $this->getRootTablePrimaryKey(); + $primaryKeys = (array) $this->getFactory()->getRootTableRegistry()->getPrimaryKey(); $res = []; foreach ($primaryKeys as $pk) { $res[$pk] = $this->generateRandomPrimaryKey( @@ -416,7 +425,7 @@ public function setPrimaryKeyOffset($primaryKeyOffset) { if (is_int($primaryKeyOffset) || is_string($primaryKeyOffset)) { $this->primaryKeyOffset = [ - $this->getRootTablePrimaryKey() => $primaryKeyOffset + $this->getFactory()->getRootTableRegistry()->getPrimaryKey() => $primaryKeyOffset ]; } elseif (is_array($primaryKeyOffset)) { $this->primaryKeyOffset = $primaryKeyOffset; @@ -442,6 +451,25 @@ private function updatePostgresSequence(array $primaryKeys) } } + /** + * Fetch the fields that were intentionally modified by the developer + * and that are unique. These should be watched for uniqueness. + * + * @return array + */ + public function getModifiedUniqueFields(): array + { + return array_values( + array_intersect( + $this->getEnforcedFields(), + array_merge( + $this->getFactory()->getUniqueProperties(), + (array)$this->getFactory()->getRootTableRegistry()->getPrimaryKey() + ) + ) + ); + } + /** * @return bool */ @@ -459,4 +487,29 @@ public function endPersistMode() { self::$inPersistMode = false; } + + /** + * @return array + */ + public function getEnforcedFields(): array + { + return $this->enforcedFields; + } + + /** + * When a field is set in the factory instantiation + * or in a patchData, save the name of the fields that + * have been set by the user. This is useful for the + * uniqueness of the fields. + * + * @param array $fields Fields to be marked as enforced. + * @return void + */ + public function addEnforcedFields(array $fields) + { + $this->enforcedFields = array_merge( + array_keys($fields), + $this->enforcedFields + ); + } } diff --git a/src/Factory/EventCollector.php b/src/Factory/EventCollector.php index 9eea0cf8..474a325b 100644 --- a/src/Factory/EventCollector.php +++ b/src/Factory/EventCollector.php @@ -17,10 +17,13 @@ use Cake\Core\Configure; use Cake\ORM\Table; -use CakephpFixtureFactories\ORM\TableRegistry\FactoryTableRegistry; +use CakephpFixtureFactories\ORM\FactoryTableRegistry; class EventCollector { + public const MODEL_EVENTS = 'CakephpFixtureFactoriesListeningModelEvents'; + public const MODEL_BEHAVIORS = 'CakephpFixtureFactoriesListeningBehaviors'; + /** * @var array */ @@ -34,7 +37,7 @@ class EventCollector /** * @var array */ - private $defaultListeningBehaviors; + private $defaultListeningBehaviors = []; /** * @var BaseFactory @@ -59,14 +62,17 @@ public function __construct(BaseFactory $factory, string $rootTableRegistryName) } /** - * Create a table cloned from the TableRegistry and per default without Model Events + * Create a table cloned from the TableRegistry + * and per default without Model Events. * @return Table */ public function getTable(): Table { - $options = []; - $options['CakephpFixtureFactoriesListeningModelEvents'] = $this->getListeningModelEvents() ?? []; - $options['CakephpFixtureFactoriesListeningBehaviors'] = $this->getListeningBehaviors() ?? []; + $options = [ + self::MODEL_EVENTS => $this->getListeningModelEvents(), + self::MODEL_BEHAVIORS => $this->getListeningBehaviors(), + ]; + try { $table = FactoryTableRegistry::getTableLocator()->get($this->rootTableRegistryName, $options); } catch (\RuntimeException $exception) { @@ -85,20 +91,23 @@ public function getListeningBehaviors(): array } /** - * @param array|string $activeBehaviors + * @param array $activeBehaviors Behaviors the factory will listen to + * @return array */ - public function listeningToBehaviors($activeBehaviors) + public function listeningToBehaviors($activeBehaviors): array { $activeBehaviors = (array) $activeBehaviors; - $this->listeningBehaviors = array_merge($this->defaultListeningBehaviors, $activeBehaviors); + + return $this->listeningBehaviors = array_merge($this->defaultListeningBehaviors, $activeBehaviors); } /** - * @param array|string $activeModelEvents + * @param array $activeModelEvents Events the factory will listen to + * @return array */ - public function listeningToModelEvents($activeModelEvents) + public function listeningToModelEvents($activeModelEvents): array { - $this->listeningModelEvents = (array) $activeModelEvents; + return $this->listeningModelEvents = (array) $activeModelEvents; } /** diff --git a/src/Factory/UniquenessJanitor.php b/src/Factory/UniquenessJanitor.php new file mode 100644 index 00000000..08299f5b --- /dev/null +++ b/src/Factory/UniquenessJanitor.php @@ -0,0 +1,97 @@ +getUniqueProperties())) { + return $entities; + } + + $originalEntities = $entities; + + // Remove associated fields and non-unique fields + foreach ($entities as &$entity) { + foreach ($entity as $k => $v) { + if (is_array($v) || !in_array($k, $factory->getUniqueProperties())) { + unset($entity[$k]); + } + } + } + if (empty($entities)) { + return $originalEntities; + } + + $entities = Hash::flatten($entities); + + // Extract the key after the dot + $getPropertyName = function (string $str): string { + return substr($str, strrpos($str, '.') + 1); + }; + + // Extract the key before the dot + $getIndex = function (string $str): int { + return (int)substr($str, 0, strrpos($str, '.')); + }; + + $propertyIsUnique = function (string $property) use ($factory): bool { + return in_array($property, array_merge( + $factory->getUniqueProperties(), + (array)$factory->getRootTableRegistry()->getPrimaryKey() + )); + }; + + $indexesToRemove = []; + foreach ($entities as $k1 => &$v1) { + unset($entities[$k1]); + if (empty($v1)) { + continue; + } + $property = $getPropertyName($k1); + foreach ($entities as $k2 => $v2) { + if ($v1 == $v2 && $property === $getPropertyName($k2) && $propertyIsUnique($property)) { + if ($isStrict) { + $factoryName = get_class($factory); + throw new UniquenessException( + "Error in {$factoryName}. The uniqueness of {$property} was not respected." + ); + } else { + $indexesToRemove[] = $getIndex($k2); + } + } + } + } + foreach (array_unique($indexesToRemove) as $i) { + unset($originalEntities[$i]); + } + + return array_values($originalEntities); + } +} diff --git a/src/ORM/FactoryTableBeforeSave.php b/src/ORM/FactoryTableBeforeSave.php new file mode 100644 index 00000000..90969968 --- /dev/null +++ b/src/ORM/FactoryTableBeforeSave.php @@ -0,0 +1,204 @@ +setTable($table); + $this->setEntity($entity); + } + + /** + * @param \Cake\ORM\Table $table Table on which the beforeFind actions are taken. + * @param \Cake\Datasource\EntityInterface $entity Entity concerned by the saving. + * @return void + */ + public static function handle(Table $table, EntityInterface $entity): void + { + $handler = new static($table, $entity); + + $handler->handleUniqueFields(); + } + + /** + * This is triggered only in associated entities. + * Fetched in the entity the properties marked by the data compiler + * as unique and non-random. Look for duplicates. If found, no + * new associated entity is created, but the exisiting gets updated. + * + * @return void + */ + public function handleUniqueFields(): void + { + $filter = $this->getEnforcedUniquePropertyValues(); + + if (!empty($filter) && $this->getIsAssociated()) { + $duplicate = $this->findDuplicate($filter); + if ($duplicate) { + $this->patchDuplicateOntoEntity($duplicate); + } + } + + $this->unsetEntityTemporaryProperties(); + } + + /** + * Get the entities that the datacompiler marked as dirty + * while creating then entity + * + * @see DataCompiler::compileEntity() + * @return array + */ + public function getEntityModifiedUniqueProperties(): array + { + return $this->getEntity()->get(DataCompiler::MODIFIED_UNIQUE_PROPERTIES) ?? []; + } + + /** + * @return bool + */ + public function getIsAssociated(): bool + { + return $this->getEntity()->get(DataCompiler::IS_ASSOCIATED) ?? false; + } + + /** + * @return void + */ + public function unsetEntityTemporaryProperties() + { + $this->getEntity()->unsetProperty(DataCompiler::MODIFIED_UNIQUE_PROPERTIES); + $this->getEntity()->unsetProperty(DataCompiler::IS_ASSOCIATED); + } + + /** + * @param array $conditions Conditions that a duplicate should meet + * @return array|null + */ + public function findDuplicate(array $conditions) + { + return $this->getTable() + ->find() + ->select($this->getPropertiesToPatchFromDuplicate()) + ->where($conditions) + ->disableHydration() + ->first(); + } + + /** + * Knowing which fields were specified by the developer in the the factory, + * extract their values in the entity in an array to prepare a search + * for a duplicate. + * + * @return array + */ + public function getEnforcedUniquePropertyValues(): array + { + $filter = []; + foreach ($this->getEntityModifiedUniqueProperties() as $uniqueField) { + $filter[$uniqueField] = $this->getEntity()->get($uniqueField); + } + + return $filter; + } + + /** + * If the entity about to be saved has a duplicate, + * the primary keys and the fields modified unique + * fields shall be overwritten by the already existing entity. + * + * @param array $duplicate Values to patch from the existing entity to the one about to be created. + * @return void + */ + public function patchDuplicateOntoEntity(array $duplicate): void + { + $this->getEntity()->isNew(false); + $this->getEntity()->clean(); + foreach ($this->getPropertiesToPatchFromDuplicate() as $field) { + $this->getEntity()->set($field, $duplicate[$field]); + } + } + + /** + * Merge unique fields enforced by the developer and the + * primary keys. Those will define which fields to search + * in duplicate. + * + * @return array + */ + public function getPropertiesToPatchFromDuplicate(): array + { + return array_unique( + array_merge( + (array)$this->getTable()->getPrimaryKey(), + $this->getEntityModifiedUniqueProperties() + ) + ); + } + + /** + * @return \Cake\ORM\Table + */ + public function getTable(): Table + { + return $this->table; + } + + /** + * @param \Cake\ORM\Table $table The class's table. + * @return void + */ + public function setTable(Table $table): void + { + $this->table = $table; + } + + /** + * @return \Cake\Datasource\EntityInterface + */ + public function getEntity(): EntityInterface + { + return $this->entity; + } + + /** + * @param \Cake\Datasource\EntityInterface $entity The class's entitiy. + * @return void + */ + public function setEntity(EntityInterface $entity): void + { + $this->entity = $entity; + } +} diff --git a/src/ORM/Locator/FactoryTableLocator.php b/src/ORM/FactoryTableLocator.php similarity index 65% rename from src/ORM/Locator/FactoryTableLocator.php rename to src/ORM/FactoryTableLocator.php index 240c8459..c249f1e8 100644 --- a/src/ORM/Locator/FactoryTableLocator.php +++ b/src/ORM/FactoryTableLocator.php @@ -11,27 +11,29 @@ * @since 1.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ -namespace CakephpFixtureFactories\ORM\Locator; +namespace CakephpFixtureFactories\ORM; use Cake\ORM\Locator\TableLocator; use Cake\ORM\Table; use CakephpFixtureFactories\Event\ModelEventsHandler; +use CakephpFixtureFactories\Factory\EventCollector; class FactoryTableLocator extends TableLocator { protected function _create(array $options): Table { - $options['CakephpFixtureFactoriesListeningModelEvents'] = $options['CakephpFixtureFactoriesListeningModelEvents'] ?? []; - $options['CakephpFixtureFactoriesListeningBehaviors'] = $options['CakephpFixtureFactoriesListeningBehaviors'] ?? []; - $cloneTable = parent::_create($options); ModelEventsHandler::handle( $cloneTable, - $options['CakephpFixtureFactoriesListeningModelEvents'], - $options['CakephpFixtureFactoriesListeningBehaviors'] + $options[EventCollector::MODEL_EVENTS] ?? [], + $options[EventCollector::MODEL_BEHAVIORS] ?? [] ); + $cloneTable->getEventManager()->on('Model.beforeSave', function ($event, $entity, $options) use ($cloneTable) { + FactoryTableBeforeSave::handle($cloneTable, $entity); + }); + return $cloneTable; } } diff --git a/src/ORM/TableRegistry/FactoryTableRegistry.php b/src/ORM/FactoryTableRegistry.php similarity index 94% rename from src/ORM/TableRegistry/FactoryTableRegistry.php rename to src/ORM/FactoryTableRegistry.php index 26e6d6cb..cea651cd 100644 --- a/src/ORM/TableRegistry/FactoryTableRegistry.php +++ b/src/ORM/FactoryTableRegistry.php @@ -11,12 +11,11 @@ * @since 1.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ -namespace CakephpFixtureFactories\ORM\TableRegistry; +namespace CakephpFixtureFactories\ORM; use Cake\ORM\Locator\LocatorInterface; use Cake\ORM\Locator\TableLocator; use Cake\ORM\TableRegistry; -use CakephpFixtureFactories\ORM\Locator\FactoryTableLocator; /** * Alternative TableRegistry to be used by fixture factories diff --git a/tests/Factory/CityFactory.php b/tests/Factory/CityFactory.php index 1cd64f27..6e5faaf6 100644 --- a/tests/Factory/CityFactory.php +++ b/tests/Factory/CityFactory.php @@ -18,6 +18,10 @@ class CityFactory extends BaseFactory { + protected $uniqueProperties = [ + 'virtual_unique_stamp', + ]; + protected function getRootTableRegistryName(): string { return 'Cities'; diff --git a/tests/Factory/CountryFactory.php b/tests/Factory/CountryFactory.php index b084ace5..46b8fe9f 100644 --- a/tests/Factory/CountryFactory.php +++ b/tests/Factory/CountryFactory.php @@ -18,6 +18,10 @@ class CountryFactory extends BaseFactory { + protected $uniqueProperties = [ + 'unique_stamp', + ]; + protected function getRootTableRegistryName(): string { return 'Countries'; @@ -28,6 +32,7 @@ protected function setDefaultTemplate() $this->setDefaultData(function(Generator $faker) { return [ 'name' => $faker->country, + 'unique_stamp' => $faker->uuid, ]; }); } diff --git a/tests/TestApp/config/Migrations/20200208100000_initial_migration.php b/tests/TestApp/config/Migrations/20200208100000_initial_migration.php index fa64fe57..160673dd 100644 --- a/tests/TestApp/config/Migrations/20200208100000_initial_migration.php +++ b/tests/TestApp/config/Migrations/20200208100000_initial_migration.php @@ -100,6 +100,10 @@ public function up() 'limit' => 128, 'null' => false, ]) + ->addColumn('virtual_unique_stamp', 'string', [ + 'limit' => 128, + 'null' => true, + ]) ->addColumn('country_id', 'integer', [ 'limit' => 11, 'null' => false, @@ -114,6 +118,10 @@ public function up() 'limit' => 128, 'null' => false, ]) + ->addColumn('unique_stamp', 'string', [ + 'limit' => 128, + 'null' => true, + ]) ->addTimestamps('created', 'modified') ->create(); @@ -124,6 +132,10 @@ public function up() $this->table('cities') ->addForeignKey('country_id', 'countries', 'id', ['delete'=>'RESTRICT', 'update'=>'CASCADE']) ->save(); + + $this->table('countries') + ->addIndex('unique_stamp', ['unique' => true]) + ->save(); } public function down() diff --git a/tests/TestCase/Error/PersistingExceptionTest.php b/tests/TestCase/Error/PersistingExceptionTest.php index 9cdc75dd..e69de29b 100644 --- a/tests/TestCase/Error/PersistingExceptionTest.php +++ b/tests/TestCase/Error/PersistingExceptionTest.php @@ -1,39 +0,0 @@ -expectException(PersistenceException::class); - $factory = AuthorFactory::class; - $this->expectExceptionMessage("Error in Factory $factory."); - AuthorFactory::make(['id' => 1])->persist(); - AuthorFactory::make(['id' => 1])->persist(); - } - - public function testSaveWronglyBuiltEntities() - { - $this->expectException(PersistenceException::class); - $factory = AuthorFactory::class; - $this->expectExceptionMessage("Error in Factory $factory."); - AuthorFactory::make(['id' => 1], 2)->persist(); - } -} diff --git a/tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php b/tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php new file mode 100644 index 00000000..da4f3639 --- /dev/null +++ b/tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php @@ -0,0 +1,358 @@ +Addresses = TableRegistry::getTableLocator()->get('Addresses'); + $this->Authors = TableRegistry::getTableLocator()->get('Authors'); + $this->Countries = TableRegistry::getTableLocator()->get('Countries'); + $this->Cities = TableRegistry::getTableLocator()->get('Cities'); + } + + public function tearDown(): void + { + unset($this->Addresses); + unset($this->Authors); + unset($this->Cities); + unset($this->Countries); + } + + public function testGetUniqueProperties() + { + $this->assertSame( + ['unique_stamp'], + CountryFactory::make()->getUniqueProperties() + ); + $this->assertSame( + [], + AuthorFactory::make()->getUniqueProperties() + ); + } + + public function testDetectDuplicateAndThrowErrorWhenPrimary() + { + $this->expectException(PersistenceException::class); + $unique_stamp = 'Foo'; + CountryFactory::make(compact('unique_stamp'))->persist(); + CountryFactory::make(compact('unique_stamp'))->persist(); + } + + public function testSaveEntitiesWithTheSameId() + { + $this->expectException(PersistenceException::class); + AuthorFactory::make(['id' => 1])->persist(); + AuthorFactory::make(['id' => 1])->persist(); + } + + public function testNoUniquenessCreatesMultipleEntities() + { + $nCities = 3; + CityFactory::make($nCities)->with('Country')->persist(); + $this->assertSame($nCities, $this->Cities->find()->count()); + $this->assertSame($nCities, $this->Countries->find()->count()); + } + + public function testDetectDuplicateInAssociation() + { + $unique_stamp = 'Foo'; + $originalCountry = CountryFactory::make([ + 'unique_stamp' => $unique_stamp, + 'name' => 'First save', + ])->persist(); + + $city = CityFactory::make()->withCountry([ + 'unique_stamp' => $unique_stamp, + 'name' => 'Second save', + ])->persist(); + + $newCountry = $city->get('country'); + + $this->assertSame($originalCountry->id, $newCountry->id); + $this->assertSame($city->get('country_id'), $newCountry->id); + $this->assertSame($originalCountry->unique_stamp, $unique_stamp); + $this->assertSame($newCountry->unique_stamp, $unique_stamp); + $this->assertSame(1, $this->Countries->find()->count()); + } + + /** + * @Given an author is created + * @When an article with that same author is created + * @Then the author is not created again, but updated. + */ + public function testDetectDuplicatePrimaryKeyInAssociation() + { + $authorId = rand(); + $originalAuthor = AuthorFactory::make([ + 'id' => $authorId, + ])->persist(); + + $authorName = 'Foo'; + $article = ArticleFactory::make()->with('Authors', [ + 'id' => $authorId, + 'name' => $authorName + ])->persist(); + + $newAuthor = $article->get('authors')[0]; + + $this->assertSame($originalAuthor->id, $newAuthor->id); + $this->assertSame($authorName, $newAuthor->name); + $this->assertSame(1, $this->Authors->find()->count()); + $this->assertSame(1, $this->Addresses->find()->count()); + } + + /** + * @Given we instantiate a factory with a unique field + * with two entries. + * @When we get entities + * @Then An Exception is thrown + */ + public function testDetectDuplicateInInstantiation() + { + $this->expectException(UniquenessException::class); + $factoryName = CountryFactory::class; + $this->expectExceptionMessage("Error in {$factoryName}. The uniqueness of unique_stamp was not respected."); + + $unique_stamp = 'Foo'; + + CountryFactory::make([ + compact('unique_stamp'), + compact('unique_stamp'), + ])->getEntities(); + } + + /** + * @Given we instantiate a factory with a unique field + * with two entries. + * @When we get entities + * @Then An Exception is thrown + * + * @throws \Exception + */ + public function testDetectDuplicateInInstantiationWithTimes() + { + $this->expectException(UniquenessException::class); + $factoryName = CountryFactory::class; + $this->expectExceptionMessage("Error in {$factoryName}. The uniqueness of unique_stamp was not respected."); + + $unique_stamp = 'Foo'; + + CountryFactory::make(compact('unique_stamp'), 2)->getEntities(); + } + + /** + * @Given we instantiate a factory with a unique field + * with two entries. + * @When we get entities + * @Then An Exception is thrown. + */ + public function testDetectDuplicateInPatchWithTimes() + { + $this->expectException(UniquenessException::class); + $factoryName = CountryFactory::class; + $this->expectExceptionMessage("Error in {$factoryName}. The uniqueness of unique_stamp was not respected."); + + $unique_stamp = 'Foo'; + + CountryFactory::make( 2)->patchData(compact('unique_stamp'))->getEntities(); + } + + /** + * @Given we instantiate a factory with a unique field + * with two entries. + * @When we persist + * @Then An Exception is thrown. + */ + public function testDetectDuplicateInInstantiationPersist() + { + $this->expectException(UniquenessException::class); + $factoryName = CountryFactory::class; + $this->expectExceptionMessage("Error in {$factoryName}. The uniqueness of unique_stamp was not respected."); + + $unique_stamp = 'Foo'; + + CountryFactory::make([ + compact('unique_stamp'), + compact('unique_stamp'), + ])->persist(); + } + + /** + * @Given we instantiate an associated factory with a unique field + * with two entries + * @When we get entities + * @Then An exception is thrown. + * + * @throws \Exception + */ + public function testDetectDuplicateInInstantiationWithTimesInAssociation() + { + $this->expectException(UniquenessException::class); + $factoryName = CityFactory::class; + $this->expectExceptionMessage("Error in {$factoryName}. The uniqueness of virtual_unique_stamp was not respected."); + + $virtual_unique_stamp = 'virtual_unique_stamp'; + + CountryFactory::make()->with('Cities', [ + compact('virtual_unique_stamp'), + compact('virtual_unique_stamp'), + ])->getEntities(); + } + + /** + * @Given we instantiate an associated factory with a unique field + * with two entries provided by numerically + * @When we get entities + * @Then An exception is thrown. + * + * @throws \Exception + */ + public function testDetectDuplicateInInstantiationWithTimesInAssociationNumeric() + { + $this->expectException(UniquenessException::class); + $factoryName = CityFactory::class; + $this->expectExceptionMessage("Error in {$factoryName}. The uniqueness of virtual_unique_stamp was not respected."); + + $virtual_unique_stamp = 'virtual_unique_stamp'; + + CountryFactory::make()->with('Cities[2]', compact('virtual_unique_stamp'))->getEntities(); + } + + /** + * @Given we create n countries with a common cities (imagine...) + * @When we persist + * @Then only on single city should be persisted and be associated + * to all n countries. + */ + public function testCreateSeveralEntitiesWithSameAssociationHasMany() + { + $virtual_unique_stamp = 'foo'; + + // HasMany + $nCountries = 3; + $countries = CountryFactory::make($nCountries) + ->with('Cities', compact('virtual_unique_stamp')) + ->persist(); + + $this->assertSame(1, $this->Cities->find()->count()); + $this->assertSame($nCountries, $this->Countries->find()->count()); + $cityId = $this->Cities->find()->first()->id; + foreach ($countries as $country) { + $this->assertSame($virtual_unique_stamp, $country->cities[0]->virtual_unique_stamp); + $this->assertSame($cityId, $country->cities[0]->id); + } + } + + /** + * @Given we create n cities within a country + * @When we persist + * @Then only on single country should be persisted and be associated + * to all n cities. + */ + public function testCreateSeveralEntitiesWithSameAssociationBelongsTo() + { + $unique_stamp = 'foo'; + + // BelongsTo + $nCities = 3; + $cities = CityFactory::make($nCities) + ->with('Country', compact('unique_stamp')) + ->persist(); + + $this->assertSame(1, $this->Countries->find()->count()); + $this->assertSame($nCities, $this->Cities->find()->count()); + $countryId = $this->Countries->find()->first()->id; + foreach ($cities as $city) { + $this->assertSame( $unique_stamp, $city->country->unique_stamp); + $this->assertSame($countryId, $city->country_id); + } + } + + /** + * @Given we create n cities within a country + * @When we persist + * @Then only on single country should be persisted and be associated + * to all n cities. + */ + public function testCreateSeveralEntitiesWithSameAssociationBelongsToWithChainedWith() + { + $unique_stamp = 'foo'; + + // BelongsTo + $nCities = 3; + $countryName = 'Foo'; + $cities = CityFactory::make($nCities) + ->with('Country', compact('unique_stamp')) + ->with('Country', compact('unique_stamp') + ['name' => $countryName]) + ->persist(); + + $this->assertSame(1, $this->Countries->find()->count()); + $this->assertSame($nCities, $this->Cities->find()->count()); + $retrievedCountry = $this->Countries->find()->first(); + $countryId = $retrievedCountry->id; + $this->assertSame($countryName, $retrievedCountry->name); + foreach ($cities as $city) { + $this->assertSame( $unique_stamp, $city->country->unique_stamp); + $this->assertSame($countryId, $city->country_id); + } + } + public function getFixtures(): array + { + return parent::getFixtures(); // TODO: Change the autogenerated stub + } +} \ No newline at end of file diff --git a/tests/TestCase/Factory/DataCompilerTest.php b/tests/TestCase/Factory/DataCompilerTest.php index 2b380df6..b9d12060 100644 --- a/tests/TestCase/Factory/DataCompilerTest.php +++ b/tests/TestCase/Factory/DataCompilerTest.php @@ -16,6 +16,7 @@ use Cake\TestSuite\TestCase; use CakephpFixtureFactories\Error\PersistenceException; +use CakephpFixtureFactories\Factory\BaseFactory; use CakephpFixtureFactories\Factory\DataCompiler; use CakephpFixtureFactories\Test\Factory\ArticleFactory; use CakephpFixtureFactories\Test\Factory\AuthorFactory; @@ -75,11 +76,6 @@ public function testGetMarshallerAssociationNameWithAliasedDeepAssociationName() $this->assertSame(PremiumAuthorsTable::ASSOCIATION_ALIAS.'.address', $marshallerAssociationName); } - public function testGetPrimaryKey() - { - $this->assertSame('id', $this->articleDataCompiler->getRootTablePrimaryKey()); - } - public function testGenerateRandomPrimaryKeyInteger() { $this->assertTrue(is_int($this->articleDataCompiler->generateRandomPrimaryKey('integer'))); @@ -161,4 +157,26 @@ public function testSetPrimaryKeyOnArrayOfData() $this->articleDataCompiler->endPersistMode(); } + + public function dataForGetModifiedUniqueFields(): array + { + return [ + [[], []], + [['id' => 'Foo',], ['id']], + [['id' => 'Foo', 'name' => 'Bar'], ['id']], + [['id' => 'Foo', 'name' => 'Bar', 'unique_stamp' => 'FooBar'], ['id', 'unique_stamp']], + ]; + } + + /** + * @dataProvider dataForGetModifiedUniqueFields + * @param BaseFactory $factory + * @param array $expected + */ + public function testGetModifiedUniqueFields(array $injectedData, array $expected) + { + $dataCompiler = new DataCompiler(CountryFactory::make($injectedData)); + $dataCompiler->compileEntity($injectedData); + $this->assertSame($dataCompiler->getModifiedUniqueFields(), $expected); + } } \ No newline at end of file diff --git a/tests/TestCase/Factory/UniquenessJanitorTest.php b/tests/TestCase/Factory/UniquenessJanitorTest.php new file mode 100644 index 00000000..1dff7d2c --- /dev/null +++ b/tests/TestCase/Factory/UniquenessJanitorTest.php @@ -0,0 +1,107 @@ + ['some', 'associations'], 'property_2'], false], + ]; + } + + /** + * @Given the entities get factored as primary (not as associations) + * @And two entities have given properties + * @When they share a unique property + * @Then an exception should be triggered + * + * @dataProvider dataForSanitizeEntityArrayOnPrimary + * @param array $uniqueProperties + * @param bool $expectException + */ + public function testSanitizeEntityArrayOnPrimary(array $uniqueProperties, bool $expectException) + { + $factoryStub = $this->getMockBuilder(BaseFactory::class)->disableOriginalConstructor()->getMock(); + $factoryStub->method('getUniqueProperties')->willReturn($uniqueProperties); + + $entities = [ + ['property_1' => 'foo', 'property_2' => 'foo'], + ['property_1' => 'foo', 'property_2' => 'dah'], + ]; + + if ($expectException) { + $this->expectException(UniquenessException::class); + $factoryName = get_class($factoryStub); + $this->expectExceptionMessage("Error in {$factoryName}. The uniqueness of property_1 was not respected."); + } else { + $this->assertSame(true, true); + } + + UniquenessJanitor::sanitizeEntityArray($factoryStub, $entities, true); + } + + public function dataForSanitizeEntityArrayOnAssociation() + { + $associatedData = [ + ['property_1' => 'foo', 'property_2' => 'foo'], + ['property_1' => 'foo', 'property_2' => 'dah'] + ]; + return [ + [[], $associatedData], + [['property_1'], [$associatedData[0]]], + [['property_2'], $associatedData], + [['property_3'], $associatedData], + [['property_1', 'property_2'], [$associatedData[0]]], + ]; + } + + /** + * @Given the entities get factored as association (not primary) + * @And two entities have given properties + * @When they share a unique property + * @Then the second one will be ignored. + * + * @dataProvider dataForSanitizeEntityArrayOnAssociation + * @param array $uniqueProperties + * @param array $expectOutput + */ + public function testSanitizeEntityArrayOnAssociation(array $uniqueProperties, array $expectOutput) + { + $factoryStub = $this->getMockBuilder(BaseFactory::class)->disableOriginalConstructor()->getMock(); + $factoryStub->method('getUniqueProperties')->willReturn($uniqueProperties); + + $associations = [ + ['property_1' => 'foo', 'property_2' => 'foo'], + ['property_1' => 'foo', 'property_2' => 'dah'], + ]; + + $act = UniquenessJanitor::sanitizeEntityArray($factoryStub, $associations, false); + + $this->assertSame($expectOutput, $act); + } +} \ No newline at end of file diff --git a/tests/TestCase/ORM/FactoryTableBeforeSaveTest.php b/tests/TestCase/ORM/FactoryTableBeforeSaveTest.php new file mode 100644 index 00000000..c9284211 --- /dev/null +++ b/tests/TestCase/ORM/FactoryTableBeforeSaveTest.php @@ -0,0 +1,79 @@ +persist(); + + $unique_stamp = $persistedCountry->unique_stamp; + $name = $persistedCountry->name; + $id = $persistedCountry->id; + + // Has modified unique_stamp + $duplicateCountry = CountryFactory::make(compact('id', 'unique_stamp', 'name'))->getEntity(); + $duplicateCountry->set(DataCompiler::MODIFIED_UNIQUE_PROPERTIES, ['unique_stamp']); + $beforeSaver = new FactoryTableBeforeSave(CountryFactory::make()->getTable(), $duplicateCountry); + $res = $beforeSaver->findDuplicate(compact('id')); + $expect = compact('id', 'unique_stamp'); + $this->assertSame($expect, $res); + + // Has modified id + $duplicateCountry = CountryFactory::make(compact('id', 'unique_stamp', 'name'))->getEntity(); + $duplicateCountry->set(DataCompiler::MODIFIED_UNIQUE_PROPERTIES, ['id']); + $beforeSaver = new FactoryTableBeforeSave(CountryFactory::make()->getTable(), $duplicateCountry); + $res = $beforeSaver->findDuplicate(compact('id')); + $expect = compact('id'); + $this->assertSame($expect, $res); + + // Has modified id and unique_stamp + $duplicateCountry = CountryFactory::make(compact('id', 'unique_stamp', 'name'))->getEntity(); + $duplicateCountry->set(DataCompiler::MODIFIED_UNIQUE_PROPERTIES, ['id', 'unique_stamp']); + $beforeSaver = new FactoryTableBeforeSave(CountryFactory::make()->getTable(), $duplicateCountry); + $res = $beforeSaver->findDuplicate(compact('id')); + $expect = compact('id', 'unique_stamp'); + $this->assertSame($expect, $res); + } + + public function testHandleUniqueFields() + { + $persistedCountry = CountryFactory::make()->persist(); + + $unique_stamp = $persistedCountry->unique_stamp; + $id = $persistedCountry->id; + + // Has modified id and unique_stamp + $duplicateCountry = CountryFactory::make(compact('id', 'unique_stamp'))->getEntity(); + $duplicateCountry->set(DataCompiler::MODIFIED_UNIQUE_PROPERTIES, ['id', 'unique_stamp']); + $duplicateCountry->set(DataCompiler::IS_ASSOCIATED, true); + + $beforeSaver = new FactoryTableBeforeSave(CountryFactory::make()->getTable(), $duplicateCountry); + $beforeSaver->handleUniqueFields(); + + $this->assertSame(null, $duplicateCountry->get(DataCompiler::IS_ASSOCIATED)); + $this->assertSame(null, $duplicateCountry->get(DataCompiler::MODIFIED_UNIQUE_PROPERTIES)); + $this->assertSame(false, $duplicateCountry->get('name') == $persistedCountry->get('name')); + $this->assertSame($duplicateCountry->get('id'), $persistedCountry->get('id')); + $this->assertSame($duplicateCountry->get('unique_stamp'), $persistedCountry->get('unique_stamp')); + } +} \ No newline at end of file diff --git a/tests/TestCase/ORM/TableRegistry/FactoryTableRegistryTest.php b/tests/TestCase/ORM/FactoryTableRegistryTest.php similarity index 95% rename from tests/TestCase/ORM/TableRegistry/FactoryTableRegistryTest.php rename to tests/TestCase/ORM/FactoryTableRegistryTest.php index b1315736..7edfbde7 100644 --- a/tests/TestCase/ORM/TableRegistry/FactoryTableRegistryTest.php +++ b/tests/TestCase/ORM/FactoryTableRegistryTest.php @@ -11,11 +11,11 @@ * @since 1.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ -namespace CakephpFixtureFactories\Test\TestCase\ORM\TableRegistry; +namespace CakephpFixtureFactories\Test\TestCase\ORM; use Cake\ORM\TableRegistry; use Cake\TestSuite\TestCase; -use CakephpFixtureFactories\ORM\TableRegistry\FactoryTableRegistry; +use CakephpFixtureFactories\ORM\FactoryTableRegistry; use CakephpFixtureFactories\Test\Factory\CountryFactory; use TestApp\Model\Table\AddressesTable; use TestApp\Model\Table\ArticlesTable; From 409687ea5a1233bb861957ec83b3f3c74bff2c06 Mon Sep 17 00:00:00 2001 From: jpramirez Date: Sat, 23 Jan 2021 09:32:01 +0100 Subject: [PATCH 5/9] Faker updated (#47) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index acdc85c2..402fd5c8 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=7.1.0", "cakephp/cakephp": "^3.7", - "fzaninotto/faker": "^1.9@dev", + "fakerphp/faker": "^1.13", "vierge-noire/cakephp-test-suite-light": "^1.1" }, "require-dev": { From d27fda0c01503fc176419b4138de83bf8cce09cb Mon Sep 17 00:00:00 2001 From: jpramirez Date: Sat, 23 Jan 2021 10:35:10 +0100 Subject: [PATCH 6/9] #48 cake3 phpstan cs sniffer (#50) * Composer updated * Composer updated, Cs check removed * PHPStan workflow updated --- .github/workflows/phpstan.yml | 3 --- composer.json | 3 ++- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 8fb71746..8b14221a 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -38,8 +38,5 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-progress --no-suggest --prefer-stable - - name: Composer install phpstan - run: composer require phpstan/phpstan "^0.12.48@dev" - - name: Run phpstan run: composer phpstan diff --git a/composer.json b/composer.json index 402fd5c8..57a382bc 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "require-dev": { "cakephp/bake": "^1.5", "josegonzalez/dotenv": "dev-master", + "phpstan/phpstan": "0.12.x-dev", "phpunit/phpunit": "^6.1", "vierge-noire/cakephp-test-migrator": "^1.1" }, @@ -45,7 +46,7 @@ "mysql": "bash run_tests.sh Mysql", "pgsql": "bash run_tests.sh Postgres", "sqlite": "bash run_tests.sh Sqlite", - "phpstan": "vendor/bin/phpstan analyse" + "phpstan": "vendor/bin/phpstan analyse --memory-limit=-1" }, "config": { "sort-packages": true From 8cf766a2d37349310d895ed1780e6e824fd2f817 Mon Sep 17 00:00:00 2001 From: jpramirez Date: Sat, 23 Jan 2021 11:35:23 +0100 Subject: [PATCH 7/9] #33 Handle plugin namespace (#52) --- src/Util.php | 2 +- tests/TestCase/UtilTest.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Util.php b/src/Util.php index 99d68864..ea8810aa 100644 --- a/src/Util.php +++ b/src/Util.php @@ -38,7 +38,7 @@ static public function getFactoryNamespace($plugin = null): string } else { return ( $plugin ? - $plugin : + str_replace('/', '\\', $plugin) : Configure::read('App.namespace', 'App') ) . '\Test\Factory'; } diff --git a/tests/TestCase/UtilTest.php b/tests/TestCase/UtilTest.php index 920adbe5..f25e7339 100644 --- a/tests/TestCase/UtilTest.php +++ b/tests/TestCase/UtilTest.php @@ -56,4 +56,13 @@ public function testGetFactoryNamespaceWithPlugin() Util::getFactoryNamespace($plugin) ); } + + public function testGetFactoryNamespaceWithPluginWithASlash() + { + $plugin = 'Foo/Bar'; + $this->assertEquals( + 'Foo\Bar\Test\Factory', + Util::getFactoryNamespace($plugin) + ); + } } \ No newline at end of file From 8008ca60f35fca84a8a6b7831cc3ec23ab9a027b Mon Sep 17 00:00:00 2001 From: jpramirez Date: Sat, 23 Jan 2021 19:47:50 +0100 Subject: [PATCH 8/9] #54 cake3 work with entities (#56) * #54 Work with entities instead of arrays * CakePHP 3 fix * Fix lowest dependencies --- src/Factory/BaseFactory.php | 57 +++--- src/Factory/DataCompiler.php | 192 +++++++++++------- src/Factory/UniquenessJanitor.php | 5 +- tests/TestCase/Factory/DataCompilerTest.php | 25 +-- .../Factory/UniquenessJanitorTest.php | 20 +- 5 files changed, 164 insertions(+), 135 deletions(-) diff --git a/src/Factory/BaseFactory.php b/src/Factory/BaseFactory.php index 2c6e8bf3..9874f243 100644 --- a/src/Factory/BaseFactory.php +++ b/src/Factory/BaseFactory.php @@ -15,7 +15,6 @@ use Cake\Datasource\EntityInterface; use Cake\Datasource\ResultSetInterface; -use Cake\ORM\Exception\PersistenceFailedException; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use CakephpFixtureFactories\Error\PersistenceException; @@ -212,10 +211,7 @@ public function getFaker(): Generator */ public function getEntity(): EntityInterface { - return $this->getTable()->newEntity( - $this->toArray()[0], - $this->getMarshallerOptions() - ); + return $this->toArray()[0]; } /** @@ -224,10 +220,7 @@ public function getEntity(): EntityInterface */ public function getEntities(): array { - return $this->getTable()->newEntities( - $this->toArray(), - $this->getMarshallerOptions() - ); + return $this->toArray(); } /** @@ -254,22 +247,24 @@ public function getAssociated(): array } /** - * @return array + * Fetch data from the data compiler. + * + * @return \Cake\Datasource\EntityInterface[] */ public function toArray(): array { - $data = []; + $entities = []; for ($i = 0; $i < $this->times; $i++) { $compiledData = $this->getDataCompiler()->getCompiledTemplateData(); - if (isset($compiledData[0])) { - $data = array_merge($data, $compiledData); + if (is_array($compiledData)) { + $entities = array_merge($entities, $compiledData); } else { - $data[] = $compiledData; + $entities[] = $compiledData; } } - UniquenessJanitor::sanitizeEntityArray($this, $data); + UniquenessJanitor::sanitizeEntityArray($this, $entities); - return $data; + return $entities; } /** @@ -318,15 +313,13 @@ public function persist() } /** - * @param array $data - * @return EntityInterface - * @throws PersistenceFailedException When the entity couldn't be saved + * @param \Cake\Datasource\EntityInterface $entity Entity to persist. + * @return \Cake\Datasource\EntityInterface + * @throws \Cake\ORM\Exception\PersistenceFailedException When the entity couldn't be saved */ - protected function persistOne(array $data) + protected function persistOne(EntityInterface $entity) { - $TableRegistry = $this->getTable(); - $entity = $TableRegistry->newEntity($data, $this->getMarshallerOptions()); - return $TableRegistry->saveOrFail($entity, $this->getSaveOptions()); + return $this->getTable()->saveOrFail($entity, $this->getSaveOptions()); } /** @@ -340,16 +333,18 @@ private function getSaveOptions(): array } /** - * @param array $data - * - * @return EntityInterface[]|ResultSetInterface|false False on failure, entities list on success. - * @throws Exception + * @param \Cake\Datasource\EntityInterface[] $entities Data to persist + * @return \Cake\Datasource\EntityInterface[]|\Cake\Datasource\ResultSetInterface|false False on failure, entities list on success. + * @throws \CakephpFixtureFactories\Error\PersistenceException */ - protected function persistMany(array $data) + protected function persistMany(array $entities) { - $TableRegistry = $this->getTable(); - $entities = $TableRegistry->newEntities($data, $this->getMarshallerOptions()); - return $TableRegistry->saveMany($entities, $this->getSaveOptions()); + $entities = $this->getTable()->saveMany($entities, $this->getSaveOptions()); + if ($entities === false) { + throw new PersistenceException('Error persisting many entities in ' . self::class); + } + + return $entities; } /** diff --git a/src/Factory/DataCompiler.php b/src/Factory/DataCompiler.php index ecba1bb3..bd714121 100644 --- a/src/Factory/DataCompiler.php +++ b/src/Factory/DataCompiler.php @@ -14,6 +14,7 @@ namespace CakephpFixtureFactories\Factory; +use Cake\Datasource\EntityInterface; use Cake\ORM\Association; use Cake\ORM\Association\BelongsTo; use Cake\ORM\Association\HasOne; @@ -116,82 +117,109 @@ public function dropAssociation(string $associationName) /** * Populate the factored entity - * @return array + * + * @return \Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[] */ - public function getCompiledTemplateData(): array + public function getCompiledTemplateData() { + $setPrimaryKey = $this->isInPersistMode(); + if (is_array($this->dataFromInstantiation) && isset($this->dataFromInstantiation[0])) { $compiledTemplateData = []; foreach ($this->dataFromInstantiation as $entity) { - $compiledTemplateData[] = $this->compileEntity($entity); + $compiledTemplateData[] = $this->compileEntity($entity, $setPrimaryKey); + // Only the first entity gets its primary key set. + $setPrimaryKey = false; } } else { - $compiledTemplateData = $this->compileEntity($this->dataFromInstantiation); + $compiledTemplateData = $this->compileEntity($this->dataFromInstantiation, $setPrimaryKey); } return $compiledTemplateData; } /** - * @param array|callable $injectedData - * - * @return array + * @param array|callable $injectedData Data from the injection. + * @param bool $setPrimaryKey Set the primary key if this entity is alone or the first of an array. + * @return \Cake\Datasource\EntityInterface */ - public function compileEntity($injectedData): array + public function compileEntity($injectedData, $setPrimaryKey = false): EntityInterface { - $entityData = []; + $entity = $this->getFactory()->getTable()->newEntity(); + // This order is very important!!! $this - ->mergeWithDefaultTemplate($entityData) - ->mergeWithInjectedData($entityData, $injectedData) - ->mergeWithPatchedData($entityData) - ->mergeWithAssociatedData($entityData); + ->mergeWithDefaultTemplate($entity) + ->mergeWithInjectedData($entity, $injectedData) + ->mergeWithPatchedData($entity) + ->mergeWithAssociatedData($entity); if ($this->isInPersistMode() && !empty($this->getModifiedUniqueFields())) { - $entityData[self::MODIFIED_UNIQUE_PROPERTIES] = $this->getModifiedUniqueFields(); + $entity->set(self::MODIFIED_UNIQUE_PROPERTIES, $this->getModifiedUniqueFields()); } - return $this->setPrimaryKey($entityData); + if ($setPrimaryKey) { + $this->setPrimaryKey($entity); + } + + return $entity; + } + + /** + * Helper method to patch entities with the data compiler data. + * + * @param \Cake\Datasource\EntityInterface $entity Entity to patch. + * @param array $data Data to patch. + * @return \Cake\Datasource\EntityInterface + */ + private function patchEntity(EntityInterface $entity, array $data): EntityInterface + { + return $this->getFactory()->getTable()->patchEntity( + $entity, + $data, + $this->getFactory()->getMarshallerOptions() + ); } /** * Step 1: merge the default template data - * @param array $compiledTemplateData - * @return $this + * + * @param \Cake\Datasource\EntityInterface $entity Entity being produced. + * @return self */ - private function mergeWithDefaultTemplate(array &$compiledTemplateData): self + private function mergeWithDefaultTemplate(EntityInterface $entity): self { - if (!empty($compiledTemplateData)) { - throw new FixtureFactoryException('The initial array before merging with the default template should be empty'); - } $data = $this->dataFromDefaultTemplate; - if (is_array($data)) { - $compiledTemplateData = array_merge($compiledTemplateData, $data); - } elseif (is_callable($data)) { - $compiledTemplateData = array_merge($compiledTemplateData, $data($this->getFactory()->getFaker())); + if (is_callable($data)) { + $data = $data($this->getFactory()->getFaker()); } + + $this->patchEntity($entity, $data); + return $this; } /** * Step 2: * Merge with the data injected during the instantiation of the Factory - * @param array $compiledTemplateData - * @param array|callable $injectedData - * @return $this + * + * @param \Cake\Datasource\EntityInterface $entity Entity to manipulate. + * @param array|callable $data Data from the instantiation. + * @return self */ - private function mergeWithInjectedData(array &$compiledTemplateData, $injectedData): self + private function mergeWithInjectedData(EntityInterface $entity, $data): self { - if (is_callable($injectedData)) { - $array = $injectedData( + if (is_callable($data)) { + $data = $data( $this->getFactory(), $this->getFactory()->getFaker() ); - $compiledTemplateData = array_merge($compiledTemplateData, $array); - } elseif (is_array($injectedData)) { - $compiledTemplateData = array_merge($compiledTemplateData, $injectedData); - $this->addEnforcedFields($injectedData); + } elseif (is_array($data)) { + $this->addEnforcedFields($data); } + + $this->patchEntity($entity, $data); + return $this; } @@ -202,11 +230,13 @@ private function mergeWithInjectedData(array &$compiledTemplateData, $injectedDa * modified by the user is known ("enforced fields"). * This will be passed as field to the dedicated table's * beforeFind in order to handle the uniqueness of its fields. - * @param array $compiledTemplateData + * + * @param \Cake\Datasource\EntityInterface $entity Entity to manipulate. + * @return self */ - private function mergeWithPatchedData(array &$compiledTemplateData) + private function mergeWithPatchedData(EntityInterface $entity) { - $compiledTemplateData = array_merge($compiledTemplateData, $this->dataFromPatch); + $this->patchEntity($entity, $this->dataFromPatch); $this->addEnforcedFields($this->dataFromPatch); return $this; @@ -215,9 +245,11 @@ private function mergeWithPatchedData(array &$compiledTemplateData) /** * Step 4: * Merge with the data from the associations - * @param array $compiledTemplateData + * + * @param \Cake\Datasource\EntityInterface $entity Entity produced by the factory. + * @return self */ - private function mergeWithAssociatedData(array &$compiledTemplateData): self + private function mergeWithAssociatedData(EntityInterface $entity): self { // Overwrite the default associations if these are found in the associations $associatedData = array_merge($this->dataFromDefaultAssociations, $this->dataFromAssociations); @@ -227,9 +259,9 @@ private function mergeWithAssociatedData(array &$compiledTemplateData): self $propertyName = $this->getMarshallerAssociationName($propertyName); if ($association instanceof HasOne || $association instanceof BelongsTo) { // toOne associated data must be singular when saved - $this->mergeWithToOne($compiledTemplateData, $propertyName, $data); + $this->mergeWithToOne($entity, $propertyName, $data); } else { - $this->mergeWithToMany($compiledTemplateData, $propertyName, $data); + $this->mergeWithToMany($entity, $propertyName, $data); } } return $this; @@ -240,50 +272,58 @@ private function mergeWithAssociatedData(array &$compiledTemplateData): self * One reason can be the default template value. * Here the latest inserted record is taken * - * @param array $compiledTemplateData - * @param string $associationName - * @param array $data + * @param \Cake\Datasource\EntityInterface $entity Entity produced by the factory. + * @param string $associationName Association + * @param array $data Data to inject + * @return void */ - private function mergeWithToOne(array &$compiledTemplateData, string $associationName, array $data) + private function mergeWithToOne(EntityInterface $entity, string $associationName, array $data) { $count = count($data); $associationName = Inflector::singularize($associationName); /** @var BaseFactory $factory */ $factory = $data[$count - 1]; - $compiledTemplateData[$associationName] = - $factory->getEntity()->setHidden([])->toArray() + ($this->isInPersistMode() ? [self::IS_ASSOCIATED => true] : []); + + $associatedEntity = $factory->getEntity(); + if ($this->isInPersistMode() ) { + $associatedEntity->set(self::IS_ASSOCIATED, true); + } + + $entity->set($associationName, $associatedEntity); } /** - * @param array $compiledTemplateData - * @param string $associationName - * @param array $data + * @param \Cake\Datasource\EntityInterface $entity Entity produced by the factory. + * @param string $associationName Association + * @param array $data Data to inject + * @return void */ - private function mergeWithToMany(array &$compiledTemplateData, string $associationName, array $data) + private function mergeWithToMany(EntityInterface $entity, string $associationName, array $data) { - $associationData = $compiledTemplateData[$associationName] ?? null; + $associationData = $entity->get($associationName); foreach ($data as $factory) { - if ($associationData) { - $associationData = array_merge($associationData, $this->getManyEntities($factory)); - } else { + if (empty($associationData)) { $associationData = $this->getManyEntities($factory); + } else { + $associationData = array_merge($associationData, $this->getManyEntities($factory)); } } - $compiledTemplateData[$associationName] = $associationData; + $entity->set($associationName, $associationData); } /** - * @param BaseFactory $factory - * - * @return array + * @param \CakephpFixtureFactories\Factory\BaseFactory $factory Factory + * @return \Cake\Datasource\EntityInterface[] */ private function getManyEntities(BaseFactory $factory): array { - $result = []; - foreach ($factory->getEntities() as $entity) { - $result[] = $entity->setHidden([])->toArray() + ($this->isInPersistMode() ? [self::IS_ASSOCIATED => true] : []); + $entities = $factory->getEntities(); + if ($this->isInPersistMode()) { + foreach ($entities as $entity) { + $entity->set(self::IS_ASSOCIATED, true); + } } - return $result; + return $entities; } /** @@ -330,27 +370,23 @@ public function getAssociationByPropertyName(string $propertyName) } /** - * @param array $data - * - * @return array + * @param \Cake\Datasource\EntityInterface $entity Entity to manipulate. + * @return \Cake\Datasource\EntityInterface */ - public function setPrimaryKey(array $data): array + public function setPrimaryKey(EntityInterface $entity): EntityInterface { // A set of primary keys is produced if in persistence mode, and if a first set was not produced yet if (!$this->isInPersistMode() || !is_array($this->primaryKeyOffset)) { - return $data; + return $entity; } - // If we have an array of multiple entities, set only for the first one - if (isset($data[0])) { - $data[0] = $this->setPrimaryKey($data[0]); - } else { - $data = array_merge( - $this->createPrimaryKeyOffset(), - $data - ); + foreach ($this->createPrimaryKeyOffset() as $pk => $value) { + if (!$entity->has($pk)) { + $entity->set($pk, $value); + } } - return $data; + + return $entity; } /** diff --git a/src/Factory/UniquenessJanitor.php b/src/Factory/UniquenessJanitor.php index 08299f5b..9e37e91d 100644 --- a/src/Factory/UniquenessJanitor.php +++ b/src/Factory/UniquenessJanitor.php @@ -24,9 +24,9 @@ class UniquenessJanitor * in order to warn the user that she is about to create duplicates. * * @param \CakephpFixtureFactories\Factory\BaseFactory $factory Factory on which the entity will be built. - * @param array[] $entities Array of data meant to be patched into entities. + * @param \Cake\Datasource\EntityInterface[] $entities Array of data meant to be patched into entities. * @param bool $isStrict Throw an exception if unique fields in $entities collide. - * @return array + * @return \Cake\Datasource\EntityInterface[] * @throws \CakephpFixtureFactories\Error\UniquenessException */ public static function sanitizeEntityArray(BaseFactory $factory, array $entities, bool $isStrict = true): array @@ -39,6 +39,7 @@ public static function sanitizeEntityArray(BaseFactory $factory, array $entities // Remove associated fields and non-unique fields foreach ($entities as &$entity) { + $entity = $entity->setHidden([])->toArray(); foreach ($entity as $k => $v) { if (is_array($v) || !in_array($k, $factory->getUniqueProperties())) { unset($entity[$k]); diff --git a/tests/TestCase/Factory/DataCompilerTest.php b/tests/TestCase/Factory/DataCompilerTest.php index b9d12060..31972978 100644 --- a/tests/TestCase/Factory/DataCompilerTest.php +++ b/tests/TestCase/Factory/DataCompilerTest.php @@ -14,6 +14,7 @@ namespace CakephpFixtureFactories\Test\TestCase\Factory; +use Cake\ORM\Entity; use Cake\TestSuite\TestCase; use CakephpFixtureFactories\Error\PersistenceException; use CakephpFixtureFactories\Factory\BaseFactory; @@ -116,7 +117,7 @@ public function testCreatePrimaryKeyOffset() public function testSetPrimaryKey() { - $data = CountryFactory::make()->getEntity()->toArray(); + $data = CountryFactory::make()->getEntity(); $this->articleDataCompiler->startPersistMode(); $res = $this->articleDataCompiler->setPrimaryKey($data); @@ -131,29 +132,19 @@ public function testSetPrimaryKey() public function testSetPrimaryKeyWithIdSet() { $id = rand(1, 10000); - $res = $this->articleDataCompiler->setPrimaryKey(compact('id')); + $entity = new Entity(compact('id')); + $res = $this->articleDataCompiler->setPrimaryKey($entity); $this->assertSame($id, $res['id']); } - /** - * - */ - public function testSetPrimaryKeyOnArrayOfData() + public function testSetPrimaryKeyOnEntity() { - $data = [ - CountryFactory::make()->getEntity()->toArray(), - CountryFactory::make()->getEntity()->toArray(), - ]; + $countries = CountryFactory::make(2)->getEntity(); $this->articleDataCompiler->startPersistMode(); - $res = $this->articleDataCompiler->setPrimaryKey($data); + $res = $this->articleDataCompiler->setPrimaryKey($countries); - $this->assertTrue(is_int($res[0]['id'])); - $this->assertTrue(is_null($res[1]['id'] ?? null)); - - $res = $this->articleDataCompiler->setPrimaryKey($data); - $this->assertTrue(is_null($res[0]['id'] ?? null)); - $this->assertTrue(is_null($res[1]['id'] ?? null)); + $this->assertTrue(is_int($res['id'])); $this->articleDataCompiler->endPersistMode(); } diff --git a/tests/TestCase/Factory/UniquenessJanitorTest.php b/tests/TestCase/Factory/UniquenessJanitorTest.php index 1dff7d2c..f7758095 100644 --- a/tests/TestCase/Factory/UniquenessJanitorTest.php +++ b/tests/TestCase/Factory/UniquenessJanitorTest.php @@ -15,6 +15,8 @@ namespace CakephpFixtureFactories\Test\TestCase\Factory; +use Cake\Datasource\EntityInterface; +use Cake\ORM\Entity; use Cake\TestSuite\TestCase; use CakephpFixtureFactories\Error\UniquenessException; use CakephpFixtureFactories\Factory\BaseFactory; @@ -50,8 +52,8 @@ public function testSanitizeEntityArrayOnPrimary(array $uniqueProperties, bool $ $factoryStub->method('getUniqueProperties')->willReturn($uniqueProperties); $entities = [ - ['property_1' => 'foo', 'property_2' => 'foo'], - ['property_1' => 'foo', 'property_2' => 'dah'], + new Entity(['property_1' => 'foo', 'property_2' => 'foo']), + new Entity(['property_1' => 'foo', 'property_2' => 'dah']), ]; if ($expectException) { @@ -87,7 +89,7 @@ public function dataForSanitizeEntityArrayOnAssociation() * @Then the second one will be ignored. * * @dataProvider dataForSanitizeEntityArrayOnAssociation - * @param array $uniqueProperties + * @param EntityInterface[] $uniqueProperties * @param array $expectOutput */ public function testSanitizeEntityArrayOnAssociation(array $uniqueProperties, array $expectOutput) @@ -96,12 +98,16 @@ public function testSanitizeEntityArrayOnAssociation(array $uniqueProperties, ar $factoryStub->method('getUniqueProperties')->willReturn($uniqueProperties); $associations = [ - ['property_1' => 'foo', 'property_2' => 'foo'], - ['property_1' => 'foo', 'property_2' => 'dah'], + new Entity(['property_1' => 'foo', 'property_2' => 'foo']), + new Entity(['property_1' => 'foo', 'property_2' => 'dah']), ]; - $act = UniquenessJanitor::sanitizeEntityArray($factoryStub, $associations, false); + $entities = UniquenessJanitor::sanitizeEntityArray($factoryStub, $associations, false); + + foreach ($entities as &$entity) { + $entity = $entity->toArray(); + } - $this->assertSame($expectOutput, $act); + $this->assertSame($expectOutput, $entities); } } \ No newline at end of file From 9071c5e58a65aff5f9194b835812529834652489 Mon Sep 17 00:00:00 2001 From: jpramirez Date: Tue, 26 Jan 2021 11:13:51 +0100 Subject: [PATCH 9/9] #43 Allow entities in make and with methods (#57) --- src/Factory/AssociationBuilder.php | 513 +++++++++--------- src/Factory/BaseFactory.php | 44 +- src/Factory/DataCompiler.php | 37 +- tests/Factory/ArticleFactory.php | 17 +- .../Factory/BaseFactoryMakeWithEntityTest.php | 164 ++++++ .../Factory/BaseFactoryUniqueEntitiesTest.php | 4 - .../TestCase/Factory/FixtureInjectorTest.php | 34 -- 7 files changed, 475 insertions(+), 338 deletions(-) create mode 100644 tests/TestCase/Factory/BaseFactoryMakeWithEntityTest.php diff --git a/src/Factory/AssociationBuilder.php b/src/Factory/AssociationBuilder.php index 44a48b12..4b8e9c33 100644 --- a/src/Factory/AssociationBuilder.php +++ b/src/Factory/AssociationBuilder.php @@ -1,257 +1,258 @@ -factory = $factory; - } - - /** - * Makes sure that a given association is well defined in the - * builder's factory's table - * @param string $associationName - * @return Association - */ - public function getAssociation(string $associationName): Association - { - $this->removeBrackets($associationName); - - try { - $association = $this->getTable()->getAssociation($associationName); - } catch (\Exception $e) { - throw new AssociationBuilderException($e->getMessage()); - } - if ($this->associationIsToOne($association) || $this->associationIsToMany($association)) { - return $association; - } else { - $associationType = get_class($association); - throw new AssociationBuilderException("Unknown association type $associationType on table {$this->getTable()->getAlias()}"); - } - } - - /** - * Collect an associated factory to the BaseFactory - * @param string $associationName - * @param BaseFactory $factory - */ - public function collectAssociatedFactory(string $associationName, BaseFactory $factory) - { - $associations = $this->getAssociated(); - - if (!in_array($associationName, $associations)) { - $associations[$associationName] = $factory->getMarshallerOptions(); - } - - $this->setAssociated($associations); - } - - public function processToOneAssociation(string $associationName, BaseFactory $associationFactory) - { - $this->validateToOneAssociation($associationName, $associationFactory); - $this->removeAssociatedAssociationForToOneFactory($associationName, $associationFactory); - } - - /** - * HasOne and BelongsTo association cannot be multiple - * @param string $associationName - * @param BaseFactory $associationFactory - * @return bool - */ - public function validateToOneAssociation(string $associationName, BaseFactory $associationFactory): bool - { - if ($this->associationIsToOne($this->getAssociation($associationName)) && $associationFactory->getTimes() > 1) { - throw new AssociationBuilderException( - "Association $associationName on " . $this->getTable()->getEntityClass() . " cannot be multiple"); - } - return true; - } - - public function removeAssociatedAssociationForToOneFactory(string $associationName, BaseFactory $associatedFactory) - { - if ($this->associationIsToOne($this->getAssociation($associationName))) { - return; - } - - $thisFactoryRegistryName = $this->getTable()->getRegistryAlias(); - $associatedFactoryTable = $associatedFactory->getRootTableRegistry(); - - $associatedAssociationName = Inflector::singularize($thisFactoryRegistryName); - - if ($associatedFactoryTable->hasAssociation($associatedAssociationName)) { - $associatedFactory->without($associatedAssociationName); - } - } - - /** - * Get the factory for the association - * @param string $associationName - * @param array $data - * @return BaseFactory - */ - public function getAssociatedFactory(string $associationName, $data = []): BaseFactory - { - $associations = explode('.', $associationName); - $firstAssociation = array_shift($associations); - - $times = $this->getTimeBetweenBrackets($firstAssociation); - $this->removeBrackets($firstAssociation); - - $table = $this->getTable()->getAssociation($firstAssociation)->getClassName() ?? $this->getTable()->getAssociation($firstAssociation)->getName(); - - if (!empty($associations)) { - $factory = $this->getFactoryFromTableName($table); - $factory->with(implode('.', $associations), $data); - } else { - $factory = $this->getFactoryFromTableName($table, $data); - } - if ($times) { - $factory->setTimes($times); - } - return $factory; - } - - /** - * Get a factory from a table name - * @param string $modelName - * @param array $data - * @return BaseFactory - */ - public function getFactoryFromTableName(string $modelName, $data = []): BaseFactory - { - $factoryName = Util::getFactoryClassFromModelName($modelName); - try { - return $factoryName::make($data); - } catch (\Error $e) { - throw new AssociationBuilderException($e->getMessage()); - } - } - - /** - * Remove the brackets and there content in a n 'Association1[i].Association2[j]' formatted string - * @param string $string - * @return string - */ - public function removeBrackets(string &$string): string - { - return $string = preg_replace("/\[[^]]+\]/","", $string); - } - - /** - * Return the integer i between brackets in an 'Association[i]' formatted string - * @param string $string - * @return int|null - */ - public function getTimeBetweenBrackets(string $string) - { - preg_match_all("/\[([^\]]*)\]/", $string, $matches); - $res = $matches[1]; - if (empty($res)) { - return null; - } elseif (count($res) === 1 && !empty($res[0])) { - return (int) $res[0]; - } else { - throw new AssociationBuilderException("Error parsing $string."); - } - } - - /** - * @return BaseFactory - */ - public function getFactory(): BaseFactory - { - return $this->factory; - } - - /** - * @param Association $association - * @return bool - */ - public function associationIsToOne(Association $association): bool - { - return ($association instanceof HasOne || $association instanceof BelongsTo); - } - - /** - * @param Association $association - * @return bool - */ - public function associationIsToMany(Association $association): bool - { - return ($association instanceof HasMany || $association instanceof BelongsToMany); - } - - /** - * Scan for all associations starting by the $association path provided and drop them - * @param string $associationName - * @return void - */ - public function dropAssociation(string $associationName) - { - $this->setAssociated( - Hash::remove( - $this->getAssociated(), - $associationName - ) - ); - } - - /** - * @return array - */ - public function getAssociated(): array - { - return $this->associated; - } - - /** - * @param array $associated - */ - public function setAssociated(array $associated) - { - $this->associated = $associated; - } - - /** - * @return \Cake\ORM\Table - */ - public function getTable(): \Cake\ORM\Table - { - return $this->getFactory()->getRootTableRegistry(); - } +factory = $factory; + } + + /** + * Makes sure that a given association is well defined in the + * builder's factory's table + * @param string $associationName + * @return Association + */ + public function getAssociation(string $associationName): Association + { + $this->removeBrackets($associationName); + + try { + $association = $this->getTable()->getAssociation($associationName); + } catch (\Exception $e) { + throw new AssociationBuilderException($e->getMessage()); + } + if ($this->associationIsToOne($association) || $this->associationIsToMany($association)) { + return $association; + } else { + $associationType = get_class($association); + throw new AssociationBuilderException("Unknown association type $associationType on table {$this->getTable()->getAlias()}"); + } + } + + /** + * Collect an associated factory to the BaseFactory + * @param string $associationName + * @param BaseFactory $factory + */ + public function collectAssociatedFactory(string $associationName, BaseFactory $factory) + { + $associations = $this->getAssociated(); + + if (!in_array($associationName, $associations)) { + $associations[$associationName] = $factory->getMarshallerOptions(); + } + + $this->setAssociated($associations); + } + + public function processToOneAssociation(string $associationName, BaseFactory $associationFactory) + { + $this->validateToOneAssociation($associationName, $associationFactory); + $this->removeAssociatedAssociationForToOneFactory($associationName, $associationFactory); + } + + /** + * HasOne and BelongsTo association cannot be multiple + * @param string $associationName + * @param BaseFactory $associationFactory + * @return bool + */ + public function validateToOneAssociation(string $associationName, BaseFactory $associationFactory): bool + { + if ($this->associationIsToOne($this->getAssociation($associationName)) && $associationFactory->getTimes() > 1) { + throw new AssociationBuilderException( + "Association $associationName on " . $this->getTable()->getEntityClass() . " cannot be multiple"); + } + return true; + } + + public function removeAssociatedAssociationForToOneFactory(string $associationName, BaseFactory $associatedFactory) + { + if ($this->associationIsToOne($this->getAssociation($associationName))) { + return; + } + + $thisFactoryRegistryName = $this->getTable()->getRegistryAlias(); + $associatedFactoryTable = $associatedFactory->getRootTableRegistry(); + + $associatedAssociationName = Inflector::singularize($thisFactoryRegistryName); + + if ($associatedFactoryTable->hasAssociation($associatedAssociationName)) { + $associatedFactory->without($associatedAssociationName); + } + } + + /** + * Get the factory for the association + * + * @param string $associationName Association name + * @param array|\CakephpFixtureFactories\Factory\BaseFactory|\Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[] $data Injected data + * @return \CakephpFixtureFactories\Factory\BaseFactory + */ + public function getAssociatedFactory(string $associationName, $data = []): BaseFactory + { + $associations = explode('.', $associationName); + $firstAssociation = array_shift($associations); + + $times = $this->getTimeBetweenBrackets($firstAssociation); + $this->removeBrackets($firstAssociation); + + $table = $this->getTable()->getAssociation($firstAssociation)->getClassName() ?? $this->getTable()->getAssociation($firstAssociation)->getName(); + + if (!empty($associations)) { + $factory = $this->getFactoryFromTableName($table); + $factory->with(implode('.', $associations), $data); + } else { + $factory = $this->getFactoryFromTableName($table, $data); + } + if ($times) { + $factory->setTimes($times); + } + return $factory; + } + + /** + * Get a factory from a table name + * @param string $modelName + * @param array $data + * @return BaseFactory + */ + public function getFactoryFromTableName(string $modelName, $data = []): BaseFactory + { + $factoryName = Util::getFactoryClassFromModelName($modelName); + try { + return $factoryName::make($data); + } catch (\Error $e) { + throw new AssociationBuilderException($e->getMessage()); + } + } + + /** + * Remove the brackets and there content in a n 'Association1[i].Association2[j]' formatted string + * @param string $string + * @return string + */ + public function removeBrackets(string &$string): string + { + return $string = preg_replace("/\[[^]]+\]/","", $string); + } + + /** + * Return the integer i between brackets in an 'Association[i]' formatted string + * @param string $string + * @return int|null + */ + public function getTimeBetweenBrackets(string $string) + { + preg_match_all("/\[([^\]]*)\]/", $string, $matches); + $res = $matches[1]; + if (empty($res)) { + return null; + } elseif (count($res) === 1 && !empty($res[0])) { + return (int) $res[0]; + } else { + throw new AssociationBuilderException("Error parsing $string."); + } + } + + /** + * @return BaseFactory + */ + public function getFactory(): BaseFactory + { + return $this->factory; + } + + /** + * @param Association $association + * @return bool + */ + public function associationIsToOne(Association $association): bool + { + return ($association instanceof HasOne || $association instanceof BelongsTo); + } + + /** + * @param Association $association + * @return bool + */ + public function associationIsToMany(Association $association): bool + { + return ($association instanceof HasMany || $association instanceof BelongsToMany); + } + + /** + * Scan for all associations starting by the $association path provided and drop them + * @param string $associationName + * @return void + */ + public function dropAssociation(string $associationName) + { + $this->setAssociated( + Hash::remove( + $this->getAssociated(), + $associationName + ) + ); + } + + /** + * @return array + */ + public function getAssociated(): array + { + return $this->associated; + } + + /** + * @param array $associated + */ + public function setAssociated(array $associated) + { + $this->associated = $associated; + } + + /** + * @return \Cake\ORM\Table + */ + public function getTable(): \Cake\ORM\Table + { + return $this->getFactory()->getRootTableRegistry(); + } } \ No newline at end of file diff --git a/src/Factory/BaseFactory.php b/src/Factory/BaseFactory.php index 9874f243..12b819e8 100644 --- a/src/Factory/BaseFactory.php +++ b/src/Factory/BaseFactory.php @@ -116,23 +116,25 @@ abstract protected function getRootTableRegistryName(): string; abstract protected function setDefaultTemplate(); /** - * @param array|callable|null|int $makeParameter - * @param int $times + * @param array|callable|null|int|\Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[] $makeParameter Injected data + * @param int $times Number of entities created * @return static */ public static function make($makeParameter = [], int $times = 1): BaseFactory { if (is_numeric($makeParameter)) { - $factory = static::makeFromArray(); + $factory = static::makeFromNonCallable(); $times = $makeParameter; } elseif (is_null($makeParameter)) { - $factory = static::makeFromArray(); - } elseif (is_array($makeParameter)) { - $factory = static::makeFromArray($makeParameter); + $factory = static::makeFromNonCallable(); + } elseif (is_array($makeParameter) || $makeParameter instanceof EntityInterface) { + $factory = static::makeFromNonCallable($makeParameter); } elseif (is_callable($makeParameter)) { $factory = static::makeFromCallable($makeParameter); } else { - throw new InvalidArgumentException("make only accepts null, an array or a callable as the first parameter"); + throw new InvalidArgumentException(' + ::make only accepts an array, an integer, an EntityInterface or a callable as first parameter. + '); } $factory->setUp($factory, $times); @@ -170,13 +172,14 @@ public static function makeWithModelEvents($makeParameter = [], $times = 1): Bas } /** - * @param array $data + * @param array|\Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[] $data Injected data * @return static */ - private static function makeFromArray(array $data = []): BaseFactory + private static function makeFromNonCallable($data = []): BaseFactory { $factory = new static(); - $factory->getDataCompiler()->collectFromArray($data); + $factory->getDataCompiler()->collectFromInstantiation($data); + return $factory; } @@ -247,11 +250,11 @@ public function getAssociated(): array } /** - * Fetch data from the data compiler. + * Fetch entities from the data compiler. * * @return \Cake\Datasource\EntityInterface[] */ - public function toArray(): array + protected function toArray(): array { $entities = []; for ($i = 0; $i < $this->times; $i++) { @@ -296,14 +299,14 @@ public function getRootTableRegistry(): Table public function persist() { $this->getDataCompiler()->startPersistMode(); - $data = $this->toArray(); + $entities = $this->toArray(); $this->getDataCompiler()->endPersistMode(); try { - if (count($data) === 1) { - return $this->persistOne($data[0]); + if (count($entities) === 1) { + return $this->persistOne($entities[0]); } else { - return $this->persistMany($data); + return $this->persistMany($entities); } } catch (Exception $exception) { $factory = get_class($this); @@ -486,10 +489,11 @@ protected function setDefaultData(callable $fn): self /** * Add associated entities to the fixtures generated by the factory - * The associated name can be of several level, dot separated - * The data can be an array, an integer, a callable or a factory - * @param string $associationName - * @param array|int|callable|BaseFactory $data + * The associated name can be of several depth, dot separated + * The data can be an array, an integer, an entity interface, a callable or a factory + * + * @param string $associationName Association name + * @param array|int|callable|\CakephpFixtureFactories\Factory\BaseFactory|\Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[] $data Injected data * @return $this */ public function with(string $associationName, $data = []): self diff --git a/src/Factory/DataCompiler.php b/src/Factory/DataCompiler.php index bd714121..7c01ea01 100644 --- a/src/Factory/DataCompiler.php +++ b/src/Factory/DataCompiler.php @@ -55,9 +55,11 @@ public function __construct(BaseFactory $factory) /** * Data passed in the instantiation by array - * @param array $data + * + * @param array|\Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[] $data Injected data. + * @return void */ - public function collectFromArray(array $data) + public function collectFromInstantiation($data) { $this->dataFromInstantiation = $data; } @@ -139,20 +141,20 @@ public function getCompiledTemplateData() } /** - * @param array|callable $injectedData Data from the injection. + * @param array|callable|\Cake\Datasource\EntityInterface $injectedData Data from the injection. * @param bool $setPrimaryKey Set the primary key if this entity is alone or the first of an array. * @return \Cake\Datasource\EntityInterface */ public function compileEntity($injectedData, $setPrimaryKey = false): EntityInterface { - $entity = $this->getFactory()->getTable()->newEntity(); + if ($injectedData instanceof EntityInterface) { + $entity = $injectedData; + } else { + $entity = $this->getEntityFromDefaultTemplate(); + $this->mergeWithInjectedData($entity, $injectedData); + } - // This order is very important!!! - $this - ->mergeWithDefaultTemplate($entity) - ->mergeWithInjectedData($entity, $injectedData) - ->mergeWithPatchedData($entity) - ->mergeWithAssociatedData($entity); + $this->mergeWithPatchedData($entity)->mergeWithAssociatedData($entity); if ($this->isInPersistMode() && !empty($this->getModifiedUniqueFields())) { $entity->set(self::MODIFIED_UNIQUE_PROPERTIES, $this->getModifiedUniqueFields()); @@ -174,7 +176,7 @@ public function compileEntity($injectedData, $setPrimaryKey = false): EntityInte */ private function patchEntity(EntityInterface $entity, array $data): EntityInterface { - return $this->getFactory()->getTable()->patchEntity( + return empty($data) ? $entity : $this->getFactory()->getTable()->patchEntity( $entity, $data, $this->getFactory()->getMarshallerOptions() @@ -182,21 +184,18 @@ private function patchEntity(EntityInterface $entity, array $data): EntityInterf } /** - * Step 1: merge the default template data + * Step 1: Create an entity from the default template. * - * @param \Cake\Datasource\EntityInterface $entity Entity being produced. - * @return self + * @return \Cake\Datasource\EntityInterface */ - private function mergeWithDefaultTemplate(EntityInterface $entity): self + private function getEntityFromDefaultTemplate(): EntityInterface { $data = $this->dataFromDefaultTemplate; if (is_callable($data)) { $data = $data($this->getFactory()->getFaker()); } - $this->patchEntity($entity, $data); - - return $this; + return $this->getFactory()->getTable()->newEntity($data, $this->getFactory()->getMarshallerOptions()); } /** @@ -204,7 +203,7 @@ private function mergeWithDefaultTemplate(EntityInterface $entity): self * Merge with the data injected during the instantiation of the Factory * * @param \Cake\Datasource\EntityInterface $entity Entity to manipulate. - * @param array|callable $data Data from the instantiation. + * @param array|callable|\Cake\Datasource\EntityInterface $data Data from the instantiation. * @return self */ private function mergeWithInjectedData(EntityInterface $entity, $data): self diff --git a/tests/Factory/ArticleFactory.php b/tests/Factory/ArticleFactory.php index a0811a41..577ae791 100644 --- a/tests/Factory/ArticleFactory.php +++ b/tests/Factory/ArticleFactory.php @@ -19,13 +19,16 @@ class ArticleFactory extends BaseFactory { + public const DEFAULT_NUMBER_OF_AUTHORS = 2; + /** * Defines the Table Registry used to generate entities with + * * @return string */ protected function getRootTableRegistryName(): string { - return "Articles"; + return 'Articles'; } /** @@ -33,16 +36,17 @@ protected function getRootTableRegistryName(): string * not nullable fields. * Use the patchData method to set the field values. * You may use methods of the factory here + * * @return void */ protected function setDefaultTemplate() { - $this->setDefaultData(function(Generator $faker) { + $this->setDefaultData(function (Generator $faker) { return [ - 'title' => $faker->text(120) + 'title' => $faker->text(120), ]; }) - ->withAuthors(2); + ->withAuthors(null, self::DEFAULT_NUMBER_OF_AUTHORS); } public function withAuthors($parameter = null, int $n = 1): self @@ -50,10 +54,10 @@ public function withAuthors($parameter = null, int $n = 1): self return $this->with('Authors', AuthorFactory::make($parameter, $n)); } - /** * It is important here to stop the propagation of the default template of the bills * Otherways, each bills get a new Article, which is not the one produced by the present factory + * * @param mixed $parameter * @param int $n * @return ArticleFactory @@ -66,6 +70,7 @@ public function withBills($parameter = null, int $n = 1) /** * BAD PRACTICE EXAMPLE * This method will lead to inconsistencies (see $this->withBills()) + * * @param mixed $parameter * @param int $n * @return ArticleFactory @@ -77,6 +82,7 @@ public function withBillsWithArticle($parameter = null, int $n = 1) /** * Set the Article's title + * * @param string $title * @return ArticleFactory */ @@ -87,6 +93,7 @@ public function withTitle(string $title) /** * Set the Article's title as a random job title + * * @return ArticleFactory */ public function setJobTitle() diff --git a/tests/TestCase/Factory/BaseFactoryMakeWithEntityTest.php b/tests/TestCase/Factory/BaseFactoryMakeWithEntityTest.php new file mode 100644 index 00000000..a1f611d2 --- /dev/null +++ b/tests/TestCase/Factory/BaseFactoryMakeWithEntityTest.php @@ -0,0 +1,164 @@ +Addresses = TableRegistry::getTableLocator()->get('Addresses'); + $this->Authors = TableRegistry::getTableLocator()->get('Authors'); + $this->Articles = TableRegistry::getTableLocator()->get('Articles'); + } + + public function tearDown(): void + { + unset($this->Addresses); + unset($this->Authors); + unset($this->Articles); + } + + public function dataProviderNoPersistOrPersist() + { + return [ + [true], [false], + ]; + } + + public function testMakeWithEntity() + { + $author1 = AuthorFactory::make()->getEntity(); + $author2 = AuthorFactory::make($author1)->getEntity(); + $this->assertSame($author1, $author2); + } + + public function testMakeWithEntityPersisted() + { + $author1 = AuthorFactory::make()->persist(); + $author2 = AuthorFactory::make($author1)->persist(); + $author3Name = 'Foo'; + $author3 = AuthorFactory::make($author1)->patchData(['name' => $author3Name])->persist(); + + $this->assertSame($author1, $author2); + $this->assertSame($author1->id, $author3->id); + $this->assertSame($author3Name, $author3->name); + $this->assertSame(1, $this->Authors->find()->count()); + } + + public function testMakeWithEntities() + { + $n = 2; + $authors = AuthorFactory::make($n)->persist(); + $authors2 = AuthorFactory::make($authors)->persist(); + $this->assertSame($n, count($authors2)); + $this->assertSame($authors, $authors2); + $this->assertSame($n, $this->Authors->find()->count()); + } + + public function testWithWithEntity() + { + $address = AddressFactory::make()->persist(); + $author = AuthorFactory::make()->with('Address', $address)->persist(); + $this->assertSame($address, $author->get('address')); + $this->assertSame($author->get('address_id'), $address->get('id')); + $this->assertSame(1, $this->Authors->find()->count()); + $this->assertSame(1, $this->Addresses->find()->count()); + } + + public function testWithToOneWithEntities() + { + $n = 2; + $addresses = AddressFactory::make($n)->persist(); + $author = AuthorFactory::make()->with('Address', $addresses)->persist(); + $this->assertSame($addresses[0], $author->get('address')); + $this->assertSame($author->get('address_id'), $addresses[0]->get('id')); + $this->assertSame(1, $this->Authors->find()->count()); + $this->assertSame(2, $this->Addresses->find()->count()); + } + + public function testWithToManyWithEntities() + { + $n = 2; + $articles = ArticleFactory::make($n)->persist(); + $author = AuthorFactory::make()->withArticles($articles)->persist(); + + $this->assertSame($articles, $author->get('articles')); + $this->assertSame(ArticleFactory::DEFAULT_NUMBER_OF_AUTHORS * $n + 1, $this->Authors->find()->count()); + $this->assertSame(2, $this->Articles->find()->count()); + } + + public function testMakeEntityAndTimes() + { + $n = 2; + $author1 = AuthorFactory::make()->persist(); + $authors = AuthorFactory::make($author1, $n)->persist(); + foreach ($authors as $author) { + $this->assertSame($author1, $author); + } + $this->assertSame(1, $this->Authors->find()->count()); + } + + public function testWithEntitiesAndTimes() + { + $n = 2; + $m = 3; + $authors1 = AuthorFactory::make($n)->persist(); + $authors = AuthorFactory::make($authors1, $m)->persist(); + + $count = 0; + for ($i=0; $i<$m; $i++) { + for ($j=0; $j<$n; $j++) { + $this->assertSame($authors1[$j], $authors[$count]); + $count++; + } + } + $this->assertSame($n * $m, count($authors)); + $this->assertSame($n, $this->Authors->find()->count()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php b/tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php index da4f3639..0440cc0b 100644 --- a/tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php +++ b/tests/TestCase/Factory/BaseFactoryUniqueEntitiesTest.php @@ -351,8 +351,4 @@ public function testCreateSeveralEntitiesWithSameAssociationBelongsToWithChained $this->assertSame($countryId, $city->country_id); } } - public function getFixtures(): array - { - return parent::getFixtures(); // TODO: Change the autogenerated stub - } } \ No newline at end of file diff --git a/tests/TestCase/Factory/FixtureInjectorTest.php b/tests/TestCase/Factory/FixtureInjectorTest.php index 242cf12e..93181cd8 100644 --- a/tests/TestCase/Factory/FixtureInjectorTest.php +++ b/tests/TestCase/Factory/FixtureInjectorTest.php @@ -36,17 +36,6 @@ public function setUp() ); } - /** - * 1. Create the config, no countries - * 2. Run the test again, countries seeded. Remove config - * 3. No countries left - * @return array - */ - public function feedRollBackAndMigrateIfRequired() - { - return [[1], [2], [3]]; - } - /** * For each of the data provided, their should be * 10 Articles found, which is the last value given to times @@ -78,29 +67,6 @@ public function createWithDifferentFactoriesInTheDataProvider() ]; } - /** - * @dataProvider feedRollBackAndMigrateIfRequired - * @see \SeedCountries::up() - * @see FixtureInjector::rollbackAndMigrateIfRequired() - */ - public function testRollBackAndMigrateIfRequired($i) - { - $this->markTestSkipped('The seed migrations between tests are not supported for the moment'); - $CountriesTable = TableRegistry::getTableLocator()->get('Countries'); - if ($i === 1) { - Configure::write('TestFixtureMarkedNonMigrated', [[ - 'source' => 'Seeds' - ]]); - } - if ($i === 1 || $i === 3) { - $this->assertSame(0, $CountriesTable->find()->count()); - } else { - $this->assertSame(1, $CountriesTable->find()->count()); - $this->assertSame('Test Country', $CountriesTable->find()->first()->name); - Configure::delete('TestFixtureMarkedNonMigrated'); - } - } - /** * Since there is only one factory in this data provider, * the factories will always return 10