From 446ec2163b03977ecfca0c32a0b0c1b5b3b485a2 Mon Sep 17 00:00:00 2001 From: Juan Pablo Ramirez Date: Sat, 25 Nov 2023 20:44:57 +0100 Subject: [PATCH] #235 First draft of v3.1 --- docs/examples.md | 23 +++++ ....php => CakephpFixtureFactoriesPlugin.php} | 17 +--- src/Factory/AssociationBuilder.php | 78 +++++++++------ src/Factory/BaseFactory.php | 47 ++++++--- src/ORM/SelectQueryMocker.php | 70 ++++++++++++++ .../Factory/AssociationBuilderTest.php | 40 +++++--- .../Factory/BaseFactoryDisplayFieldTest.php | 21 +--- .../Factory/BaseFactoryGetResultSetTest.php | 79 +++++++++++++++ tests/TestCase/ORM/SelectQueryMockerTest.php | 95 +++++++++++++++++++ 9 files changed, 378 insertions(+), 92 deletions(-) rename src/{Plugin.php => CakephpFixtureFactoriesPlugin.php} (62%) create mode 100644 src/ORM/SelectQueryMocker.php create mode 100644 tests/TestCase/Factory/BaseFactoryGetResultSetTest.php create mode 100644 tests/TestCase/ORM/SelectQueryMockerTest.php diff --git a/docs/examples.md b/docs/examples.md index 94dad17..43915ae 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -53,6 +53,12 @@ In order to persist the data generated, use the method `persist` instead of `get $articles = ArticleFactory::make(3)->persist(); ``` +You may want to retrieve your entities as a result set, allowing you conveniently query the entities created: +```php +$articles = ArticleFactory::make(3)->getResultSet(); // Will not persist in the DB +$articles = ArticleFactory::make(3)->getPersistedResultSet(); // Will persist in the DB +``` + Do not forget to check the [plugin's tests](../tests) for more insights! @@ -137,3 +143,20 @@ $article = ArticleFactory::make([ // or $article = ArticleFactory::make()->setField('array_field.key2', 'newValue')->getEntity(); ``` + +### Mocking select queries + +You might come across tests where you want to avoid the communication +with the database, and yet you would need to simulate the output of a select query. + +For example in a `ArticlesIndexController` you want to emulate a query returning +10 articles and want to test that the rendering is made properly. + +In your test, where `$this` is the TestCase extending [CakePHP's TestCase](https://book.cakephp.org/4/en/development/testing.html#mocking-model-methods): +```php +$articleFactory = ArticleFactory::make(10)->withAuthors(); +\CakephpFixtureFactories\ORM\SelectQueryMocker::mock($this, $articleFactory); +``` + +Any select queries on the `ArticlesTable` will now return these 10 articles with their associations. +The queries themselves, involving the interaction with the DB, should be tested elsewhere. \ No newline at end of file diff --git a/src/Plugin.php b/src/CakephpFixtureFactoriesPlugin.php similarity index 62% rename from src/Plugin.php rename to src/CakephpFixtureFactoriesPlugin.php index 4a8683d..4b4ce1d 100644 --- a/src/Plugin.php +++ b/src/CakephpFixtureFactoriesPlugin.php @@ -16,21 +16,8 @@ use Cake\Core\BasePlugin; /** - * Plugin class for migrations + * Plugin class for creating test fixtures */ -class Plugin extends BasePlugin +class CakephpFixtureFactoriesPlugin extends BasePlugin { - /** - * Plugin name. - * - * @var string|null - */ - protected ?string $name = 'CakephpFixtureFactories'; - - /** - * Don't try to load routes. - * - * @var bool - */ - protected bool $routesEnabled = false; } diff --git a/src/Factory/AssociationBuilder.php b/src/Factory/AssociationBuilder.php index 030e4a3..1287f3d 100644 --- a/src/Factory/AssociationBuilder.php +++ b/src/Factory/AssociationBuilder.php @@ -20,7 +20,6 @@ use Cake\ORM\Association\HasMany; use Cake\ORM\Association\HasOne; use Cake\ORM\Table; -use Cake\Utility\Hash; use Cake\Utility\Inflector; use CakephpFixtureFactories\Error\AssociationBuilderException; use Exception; @@ -37,7 +36,15 @@ class AssociationBuilder getFactory as getFactoryInstance; } - private array $associated = []; + /** + * @var array<\CakephpFixtureFactories\Factory\BaseFactory> + */ + private array $associations = []; + + /** + * @var array + */ + private array $manualAssociations = []; /** * @var \CakephpFixtureFactories\Factory\BaseFactory @@ -80,24 +87,6 @@ public function getAssociation(string $associationName): Association } } - /** - * Collect an associated factory to the BaseFactory - * - * @param string $associationName Association - * @param \CakephpFixtureFactories\Factory\BaseFactory $factory Factory - * @return void - */ - public function collectAssociatedFactory(string $associationName, BaseFactory $factory): void - { - $associations = $this->getAssociated(); - - if (!in_array($associationName, $associations)) { - $associations[$associationName] = $factory->getMarshallerOptions(); - } - - $this->setAssociated($associations); - } - /** * @param string $associationName Name of the association * @param \CakephpFixtureFactories\Factory\BaseFactory $associationFactory Factory @@ -256,12 +245,16 @@ public function associationIsToMany(Association $association): bool */ public function dropAssociation(string $associationName): void { - $this->setAssociated( - Hash::remove( - $this->getAssociated(), - $associationName - ) - ); + $explode = explode('.', $associationName); + $baseAssociationName = array_shift($explode); + if (!isset($this->associations[$baseAssociationName])) { + return; + } + if (count($explode) === 0) { + unset($this->associations[$baseAssociationName]); + } else { + $this->associations[$baseAssociationName]->without(implode('.', $explode)); + } } /** @@ -269,16 +262,32 @@ public function dropAssociation(string $associationName): void */ public function getAssociated(): array { - return $this->associated; + $result = []; + foreach ($this->associations as $name => $associatedFactory) { + $result[$name] = $associatedFactory->getMarshallerOptions(); + } + + return array_merge_recursive($result, $this->manualAssociations); + } + + /** + * @return array<\CakephpFixtureFactories\Factory\BaseFactory> + */ + public function getAssociations(): array + { + return $this->associations; } /** - * @param array $associated Associations of the master factory + * Add an associated factory to the BaseFactory + * + * @param string $associationName Association + * @param \CakephpFixtureFactories\Factory\BaseFactory $factory Factory * @return void */ - public function setAssociated(array $associated): void + public function addAssociation(string $associationName, BaseFactory $factory): void { - $this->associated = $associated; + $this->associations[$associationName] = $factory; } /** @@ -288,4 +297,13 @@ public function getTable(): Table { return $this->getFactory()->getTable(); } + + /** + * @param array $associations + * @return void + */ + public function addManualAssociations(array $associations): void + { + $this->manualAssociations = array_merge_recursive($associations, $this->manualAssociations); + } } diff --git a/src/Factory/BaseFactory.php b/src/Factory/BaseFactory.php index db0ef63..d19d0d0 100644 --- a/src/Factory/BaseFactory.php +++ b/src/Factory/BaseFactory.php @@ -18,6 +18,7 @@ use Cake\Datasource\ResultSetInterface; use Cake\I18n\I18n; use Cake\ORM\Query\SelectQuery; +use Cake\ORM\ResultSet; use Cake\ORM\Table; use CakephpFixtureFactories\Error\FixtureFactoryException; use CakephpFixtureFactories\Error\PersistenceException; @@ -228,6 +229,7 @@ public function getFaker(): Generator * Produce one entity from the present factory * * @return \Cake\Datasource\EntityInterface + * @deprecated Use getResultSet instead. Will be removed in v4. */ public function getEntity(): EntityInterface { @@ -238,12 +240,33 @@ public function getEntity(): EntityInterface * Produce a set of entities from the present factory * * @return array<\Cake\Datasource\EntityInterface> + * @deprecated Use getResultSet instead. Will be removed in v4. */ public function getEntities(): array { return $this->toArray(); } + /** + * Creates a result set of non-persisted entities + * + * @return \Cake\ORM\ResultSet + */ + public function getResultSet(): ResultSet + { + return new ResultSet($this->toArray()); + } + + /** + * Creates a result set of persisted entities + * + * @return \Cake\ORM\ResultSet + */ + public function getPersistedResultSet(): ResultSet + { + return new ResultSet((array)$this->persist()); + } + /** * @return array */ @@ -251,9 +274,7 @@ public function getMarshallerOptions(): array { $associated = $this->getAssociationBuilder()->getAssociated(); if (!empty($associated)) { - return array_merge($this->marshallerOptions, [ - 'associated' => $this->getAssociationBuilder()->getAssociated(), - ]); + return array_merge($this->marshallerOptions, compact('associated')); } else { return $this->marshallerOptions; } @@ -261,6 +282,7 @@ public function getMarshallerOptions(): array /** * @return array + * @deprecated will be removed in v4 */ public function getAssociated(): array { @@ -305,12 +327,16 @@ public function getTable(): Table /** * @return \Cake\Datasource\EntityInterface|\Cake\Datasource\ResultSetInterface|iterable<\Cake\Datasource\EntityInterface> * @throws \CakephpFixtureFactories\Error\PersistenceException if the entity/entities could not be saved. + * @deprecated Use getPersistedResultSet. Will be removed in v4. Use getPersistedResultSet */ public function persist(): EntityInterface|iterable|ResultSetInterface { $this->getDataCompiler()->startPersistMode(); - $entities = $this->toArray(); - $this->getDataCompiler()->endPersistMode(); + try { + $entities = $this->toArray(); + } finally { + $this->getDataCompiler()->endPersistMode(); + } try { if (count($entities) === 1) { @@ -331,7 +357,7 @@ public function persist(): EntityInterface|iterable|ResultSetInterface private function getSaveOptions(): array { return array_merge($this->saveOptions, [ - 'associated' => $this->getAssociated(), + 'associated' => $this->getAssociationBuilder()->getAssociated(), ]); } @@ -550,7 +576,7 @@ public function with(string $associationName, array|int|callable|BaseFactory|Ent $isToOne = $this->getAssociationBuilder()->processToOneAssociation($associationName, $factory); $this->getDataCompiler()->collectAssociation($associationName, $factory, $isToOne); - $this->getAssociationBuilder()->collectAssociatedFactory($associationName, $factory); + $this->getAssociationBuilder()->addAssociation($associationName, $factory); return $this; } @@ -576,12 +602,7 @@ public function without(string $association) */ public function mergeAssociated(array $data) { - $this->getAssociationBuilder()->setAssociated( - array_merge( - $this->getAssociationBuilder()->getAssociated(), - $data - ) - ); + $this->getAssociationBuilder()->addManualAssociations($data); return $this; } diff --git a/src/ORM/SelectQueryMocker.php b/src/ORM/SelectQueryMocker.php new file mode 100644 index 0000000..3933668 --- /dev/null +++ b/src/ORM/SelectQueryMocker.php @@ -0,0 +1,70 @@ + $methods The list of methods to mock + * @param array $options The config data for the mock's constructor. + * @throws \Cake\ORM\Exception\MissingTableClassException + * @return \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject + */ + public static function mock( + TestCase $testCase, + BaseFactory $factory, + ?string $alias = null, + array $methods = [], + array $options = [] + ): Table|MockObject { + $alias = $alias ?? $factory->getTable()->getAlias(); + $resultSet = $factory->getResultSet(); + $selectQueryMocked = $testCase + ->getMockBuilder(SelectQuery::class) + ->setConstructorArgs([$factory->getTable()]) + ->onlyMethods(['count', 'all']) + ->getMock(); + $selectQueryMocked + ->method('count') + ->willReturn($resultSet->count()); + $selectQueryMocked + ->method('all') + ->willReturn($resultSet); + + $queryFactoryMocked = $testCase + ->getMockBuilder(QueryFactory::class) + ->onlyMethods(['select']) + ->getMock(); + $queryFactoryMocked + ->method('select') + ->willReturn($selectQueryMocked); + + $options['queryFactory'] = $queryFactoryMocked; + + return $testCase->getMockForModel($alias, $methods, $options); + } +} diff --git a/tests/TestCase/Factory/AssociationBuilderTest.php b/tests/TestCase/Factory/AssociationBuilderTest.php index 31b9109..7f0d710 100644 --- a/tests/TestCase/Factory/AssociationBuilderTest.php +++ b/tests/TestCase/Factory/AssociationBuilderTest.php @@ -164,17 +164,18 @@ public function testGetTimeBetweenBracketsWith2Brackets() $AssociationBuilder->getTimeBetweenBrackets('Authors[1][2]'); } - public function testCollectAssociatedFactory() + public function testGetAssociatedFactory() { $AssociationBuilder = new AssociationBuilder(CityFactory::make()); - $AssociationBuilder->collectAssociatedFactory('Country', CountryFactory::make()); + $factory = CountryFactory::make(); + $AssociationBuilder->addAssociation('Country', $factory); $expected = [ - 'Country' => CountryFactory::make()->getMarshallerOptions(), + 'Country' => $factory->getMarshallerOptions(), ]; $this->assertSame($expected, $AssociationBuilder->getAssociated()); } - public function testCollectAssociatedFactoryDeep2() + public function testGetAssociatedFactoryDeep2() { $AddressFactory = AddressFactory::make()->with( 'City', @@ -191,7 +192,7 @@ public function testCollectAssociatedFactoryDeep2() $this->assertSame($expected, $AddressFactory->getAssociated()); } - public function testCollectAssociatedFactoryDeep3() + public function testGetAssociatedFactoryDeep3() { $AddressFactory = AddressFactory::make()->with( 'City', @@ -229,7 +230,7 @@ public function testCollectAssociatedFactoryDeep3() public function testDropAssociation() { $AssociationBuilder = new AssociationBuilder(AddressFactory::make()); - $AssociationBuilder->setAssociated(['City' => ['Country' => 'Foo']]); + $AssociationBuilder->addAssociation('City', CityFactory::make()); $AssociationBuilder->dropAssociation('City'); $this->assertEmpty($AssociationBuilder->getAssociated()); } @@ -237,34 +238,43 @@ public function testDropAssociation() public function testDropAssociationSingular() { $AssociationBuilder = new AssociationBuilder(AuthorFactory::make()); - $AssociationBuilder->setAssociated(['Authors']); + $AssociationBuilder->addAssociation('Authors', AuthorFactory::make()); $AssociationBuilder->dropAssociation('Author'); - $this->assertSame(['Authors'], $AssociationBuilder->getAssociated()); + $this->assertArrayHasKey('Authors', $AssociationBuilder->getAssociated()); } public function testDropAssociationDeep2() { $AssociationBuilder = new AssociationBuilder(AddressFactory::make()); - $AssociationBuilder->setAssociated(['City' => ['Country' => 'Foo', 'Bar']]); + $AssociationBuilder->addAssociation('City', CityFactory::make()->with('Country')); $AssociationBuilder->dropAssociation('City.Country'); - $this->assertSame(['City' => ['Bar']], $AssociationBuilder->getAssociated()); + $associatedFactory = $AssociationBuilder->getAssociated(); + $this->assertSame(1, count($associatedFactory)); + $this->assertArrayNotHasKey('associated', $associatedFactory); } - public function testCollectAssociatedFactoryWithoutAssociation() + public function testGetAssociatedFactoryWithoutAssociation() { $AddressFactory = AddressFactory::make()->without('City'); $this->assertEmpty($AddressFactory->getAssociated()); } - public function testCollectAssociatedFactoryWithoutAssociationDeep2() + public function testGetAssociatedFactoryWithoutAssociationDeep2() { $AddressFactory = AddressFactory::make()->without('City.Country'); - $this->assertSame(['City' => CityFactory::make()->getMarshallerOptions()], $AddressFactory->getAssociated()); + $this->assertSame( + ['City' => [ + 'validate' => false, + 'forceNew' => true, + 'accessibleFields' => ['*' => true,] + ]], + $AddressFactory->getAssociated() + ); } - public function testCollectAssociatedFactoryWithBrackets() + public function testGetAssociatedFactoryWithBrackets() { $CityFactory = CityFactory::make()->with('Addresses[5]'); @@ -283,7 +293,7 @@ public function testCollectAssociatedFactoryWithBrackets() $this->assertSame($expected, $CityFactory->getAssociated()); } - public function testCollectAssociatedFactoryWithAliasedAssociation() + public function testGetAssociatedFactoryWithAliasedAssociation() { $ArticleFactory = ArticleFactory::make() ->with('ExclusivePremiumAuthors') diff --git a/tests/TestCase/Factory/BaseFactoryDisplayFieldTest.php b/tests/TestCase/Factory/BaseFactoryDisplayFieldTest.php index 296cf62..fca6d1d 100644 --- a/tests/TestCase/Factory/BaseFactoryDisplayFieldTest.php +++ b/tests/TestCase/Factory/BaseFactoryDisplayFieldTest.php @@ -13,32 +13,13 @@ */ namespace CakephpFixtureFactories\Test\TestCase\Factory; -use Cake\Datasource\EntityInterface; -use Cake\ORM\TableRegistry; use Cake\TestSuite\TestCase; -use Cake\Utility\Inflector; use CakephpFixtureFactories\Error\FixtureFactoryException; -use CakephpFixtureFactories\Factory\BaseFactory; use CakephpFixtureFactories\Test\Factory\AddressFactory; use CakephpFixtureFactories\Test\Factory\ArticleFactory; -use CakephpFixtureFactories\Test\Factory\AuthorFactory; use CakephpFixtureFactories\Test\Factory\BillFactory; -use CakephpFixtureFactories\Test\Factory\CityFactory; use CakephpFixtureFactories\Test\Factory\CountryFactory; -use CakephpFixtureFactories\Test\Factory\CustomerFactory; -use Faker\Generator; -use TestApp\Model\Entity\Address; -use TestApp\Model\Entity\Article; -use TestApp\Model\Entity\Author; -use TestApp\Model\Entity\City; -use TestApp\Model\Entity\Country; -use TestApp\Model\Table\ArticlesTable; -use TestApp\Model\Table\CountriesTable; -use TestPlugin\Model\Entity\Bill; use TestPlugin\Model\Table\BillsTable; -use function count; -use function is_array; -use function is_int; class BaseFactoryDisplayFieldTest extends TestCase { @@ -88,6 +69,8 @@ public function testUseDisplayFieldInAssociationIfFieldIsNotSpecified_Multiple() public function testUseDisplayFieldErrorIfDisplayFieldAnArray() { $this->expectException(FixtureFactoryException::class); + $expectedMessage = "The display field of a table must be a string when injecting a string into its factory. You injected 'Some bill' in CakephpFixtureFactories\Test\Factory\BillFactory but TestPlugin\Model\Table\BillsTable's display field is not a string."; + $this->expectExceptionMessage($expectedMessage); BillFactory::make('Some bill')->persist(); } } diff --git a/tests/TestCase/Factory/BaseFactoryGetResultSetTest.php b/tests/TestCase/Factory/BaseFactoryGetResultSetTest.php new file mode 100644 index 0000000..e909b57 --- /dev/null +++ b/tests/TestCase/Factory/BaseFactoryGetResultSetTest.php @@ -0,0 +1,79 @@ + $name1], + ['name' => $name2], + ]) + ->with("Authors.Address", compact('street')); + + $articles = $isPersisted ? $factory->getPersistedResultSet() : $factory->getResultSet(); + $this->assertSame(2, $articles->count()); + $this->assertSame(!$isPersisted, is_null($articles->first()->get('id'))); + $this->assertSame($name1, $articles->first()->get('name')); + $this->assertSame($street, $articles->first()['authors'][0]['address']['street']); + $this->assertSame($name2, $articles->last()->get('name')); + $this->assertSame($street, $articles->last()['authors'][0]['address']['street']); + $this->assertInstanceOf(Address::class, $articles->first()['authors'][0]['address']); + $this->assertSame($isPersisted ? 2 : 0, ArticleFactory::count()); + } + + public function testBaseFactoryGetResultSet_With_Ids() + { + $id1 = 5; + $id2 = 10; + $countries = CountryFactory::make([ + ['id' => $id1], + ['id' => $id2], + ])->getResultSet(); + + $this->assertSame($id1, $countries->first()->get('id')); + $this->assertSame($id2, $countries->last()->get('id')); + } +} diff --git a/tests/TestCase/ORM/SelectQueryMockerTest.php b/tests/TestCase/ORM/SelectQueryMockerTest.php new file mode 100644 index 0000000..0fc14d3 --- /dev/null +++ b/tests/TestCase/ORM/SelectQueryMockerTest.php @@ -0,0 +1,95 @@ + $names[0]], + ['name' => $names[1]], + ]); + SelectQueryMocker::mock($this, $countryFactory); + + $CountriesTable = TableRegistry::getTableLocator()->get('Countries'); + $countries = $CountriesTable->find(); + $this->assertSame(2, $countries->count()); + foreach ($countries as $i => $country) { + $this->assertSame($names[$i], $country['name']); + } + + $this->assertSame(0, CountryFactory::count()); + } + + public function testSelectQueryMocker_With_Data_In_DB() + { + $names = ['Foo', 'Bar']; + $countryFactory = CountryFactory::make([ + ['name' => $names[0]], + ['name' => $names[1]], + ]); + $nCountriesInDB = rand(2, 5); + CountryFactory::make($nCountriesInDB)->persist(); + SelectQueryMocker::mock($this, $countryFactory); + + $CountriesTable = TableRegistry::getTableLocator()->get('Countries'); + $countries = $CountriesTable->find(); + $this->assertSame(2, $countries->count()); + foreach ($countries as $i => $country) { + $this->assertSame($names[$i], $country['name']); + } + + $this->assertSame($nCountriesInDB, CountryFactory::count()); + } + + public function testSelectQueryMocker_With_Associations() + { + $names = ['Foo', 'Bar']; + $cityFactory = CityFactory::make([ + ['name' => $names[0]], + ['name' => $names[1]], + ])->withCountry(); + SelectQueryMocker::mock($this, $cityFactory); + + + $CountriesTable = TableRegistry::getTableLocator()->get('Countries'); + $CitiesTable = TableRegistry::getTableLocator()->get('Cities'); + + $cities = $CitiesTable->find(); + $this->assertSame(2, $cities->count()); + foreach ($cities as $i => $city) { + $this->assertSame($names[$i], $city['name']); + $this->assertInstanceOf(Country::class, $city['country']); + } + + $countries = $CountriesTable->find(); + $this->assertSame(0, $countries->count()); + $this->assertSame([], $countries->toArray()); + + $this->assertSame(0, CountryFactory::count()); + $this->assertSame(0, CityFactory::count()); + } +}