diff --git a/src/Database/Repository/CompositeSpecification.php b/src/Database/Repository/CompositeSpecification.php new file mode 100644 index 000000000..1cbe90200 --- /dev/null +++ b/src/Database/Repository/CompositeSpecification.php @@ -0,0 +1,55 @@ + $specs + */ + public function __construct( + private array $specs, + private string $operator = 'and', + ) { + } + + /** + * @return array + */ + public function toQueries(): array + { + $queries = []; + + if ($this->operator === 'or') { + $alternatives = []; + foreach ($this->specs as $spec) { + $group = $spec->toQueries(); + if ($group === []) { + continue; + } + + $alternatives[] = \count($group) === 1 ? $group[0] : Query::and($group); + } + + return $alternatives === [] ? [] : [Query::or($alternatives)]; + } + + foreach ($this->specs as $spec) { + $queries = \array_merge($queries, $spec->toQueries()); + } + + return $queries; + } + + public function and(Specification $other): Specification + { + return new self([$this, $other], 'and'); + } + + public function or(Specification $other): Specification + { + return new self([$this, $other], 'or'); + } +} diff --git a/src/Database/Repository/Repository.php b/src/Database/Repository/Repository.php new file mode 100644 index 000000000..1147582f5 --- /dev/null +++ b/src/Database/Repository/Repository.php @@ -0,0 +1,116 @@ + */ + private array $globalScopes = []; + + public function __construct( + protected Database $db, + ) { + } + + abstract public function collection(): string; + + public function addScope(Scope $scope): void + { + $this->globalScopes[] = $scope; + } + + public function clearScopes(): void + { + $this->globalScopes = []; + } + + /** + * @param array $queries + * @return array + */ + protected function applyScopes(array $queries): array + { + foreach ($this->globalScopes as $scope) { + $queries = \array_merge($queries, $scope->apply()); + } + + return $queries; + } + + /** + * @param array $queries + * @return array + */ + public function withoutScopes(array $queries = []): array + { + return $this->db->find($this->collection(), $queries); + } + + public function findById(string $id): Document + { + return $this->db->getDocument($this->collection(), $id); + } + + /** + * @param array $queries + * @return array + */ + public function findAll(array $queries = []): array + { + return $this->db->find($this->collection(), $this->applyScopes($queries)); + } + + public function findOneBy(string $attribute, mixed $value): Document + { + $values = \is_array($value) ? $value : [$value]; + $equal = []; + foreach ($values as $item) { + if (\is_array($item) || \is_bool($item) || \is_float($item) || \is_int($item) || \is_string($item) || $item === null) { + $equal[] = $item; + } + } + + $results = $this->db->find($this->collection(), $this->applyScopes([ + Query::equal($attribute, $equal), + Query::limit(1), + ])); + + return $results[0] ?? new Document(); + } + + /** + * @param array $queries + */ + public function count(array $queries = []): int + { + return $this->db->count($this->collection(), $this->applyScopes($queries)); + } + + public function create(Document $document): Document + { + return $this->db->createDocument($this->collection(), $document); + } + + public function update(string $id, Document $document): Document + { + return $this->db->updateDocument($this->collection(), $id, $document); + } + + public function delete(string $id): bool + { + return $this->db->deleteDocument($this->collection(), $id); + } + + /** + * @param array $baseQueries + * @return array + */ + public function matching(Specification $spec, array $baseQueries = []): array + { + return $this->findAll(\array_merge($baseQueries, $spec->toQueries())); + } +} diff --git a/src/Database/Repository/Scope.php b/src/Database/Repository/Scope.php new file mode 100644 index 000000000..13df2b848 --- /dev/null +++ b/src/Database/Repository/Scope.php @@ -0,0 +1,13 @@ + + */ + public function apply(): array; +} diff --git a/src/Database/Repository/Specification.php b/src/Database/Repository/Specification.php new file mode 100644 index 000000000..d9babad56 --- /dev/null +++ b/src/Database/Repository/Specification.php @@ -0,0 +1,17 @@ + + */ + public function toQueries(): array; + + public function and(Specification $other): Specification; + + public function or(Specification $other): Specification; +} diff --git a/src/Database/Seeder/Factory.php b/src/Database/Seeder/Factory.php new file mode 100644 index 000000000..35091979f --- /dev/null +++ b/src/Database/Seeder/Factory.php @@ -0,0 +1,92 @@ + */ + private array $definitions = []; + + public function __construct(?Generator $faker = null) + { + if ($faker !== null) { + $this->faker = $faker; + + return; + } + + if (! \class_exists(FakerFactory::class)) { + throw new \RuntimeException( + 'fakerphp/faker is required to construct Factory without an injected generator' + ); + } + + $this->faker = FakerFactory::create(); + } + + public function define(string $collection, callable $definition): void + { + $this->definitions[$collection] = new FactoryDefinition($definition); + } + + /** + * @param array $overrides + */ + public function make(string $collection, array $overrides = []): Document + { + if (! isset($this->definitions[$collection])) { + throw new \RuntimeException("No factory defined for collection '{$collection}'"); + } + + /** @var array $data */ + $data = ($this->definitions[$collection]->callback)($this->faker); + + return new Document(\array_merge($data, $overrides)); + } + + /** + * @param array $overrides + * @return array + */ + public function makeMany(string $collection, int $count, array $overrides = []): array + { + $documents = []; + for ($i = 0; $i < $count; $i++) { + $documents[] = $this->make($collection, $overrides); + } + + return $documents; + } + + /** + * @param array $overrides + */ + public function create(string $collection, Database $db, array $overrides = []): Document + { + return $db->createDocument($collection, $this->make($collection, $overrides)); + } + + /** + * @param array $overrides + * @return array + */ + public function createMany(string $collection, Database $db, int $count, array $overrides = []): array + { + $documents = $this->makeMany($collection, $count, $overrides); + $db->createDocuments($collection, $documents); + + return $documents; + } + + public function getFaker(): Generator + { + return $this->faker; + } +} diff --git a/src/Database/Seeder/FactoryDefinition.php b/src/Database/Seeder/FactoryDefinition.php new file mode 100644 index 000000000..ce2fa6fc3 --- /dev/null +++ b/src/Database/Seeder/FactoryDefinition.php @@ -0,0 +1,14 @@ +callback = $callback; + } +} diff --git a/src/Database/Seeder/Fixture.php b/src/Database/Seeder/Fixture.php new file mode 100644 index 000000000..636425a57 --- /dev/null +++ b/src/Database/Seeder/Fixture.php @@ -0,0 +1,64 @@ + */ + private array $created = []; + + /** + * @param array> $documents + */ + public function load(Database $db, string $collection, array $documents): void + { + if ($documents === []) { + return; + } + + $docs = \array_map(fn (array $d) => new Document($d), $documents); + + if (\count($docs) === 1) { + $created = $db->createDocument($collection, $docs[0]); + $this->created[] = ['collection' => $collection, 'id' => $created->getId()]; + } else { + $db->createDocuments($collection, $docs, Database::INSERT_BATCH_SIZE, function (Document $created) use ($collection): void { + $this->created[] = ['collection' => $collection, 'id' => $created->getId()]; + }); + } + } + + public function cleanup(Database $db): void + { + if ($this->created === []) { + return; + } + + $grouped = []; + foreach (\array_reverse($this->created) as $entry) { + $grouped[$entry['collection']][] = $entry['id']; + } + + foreach ($grouped as $collection => $ids) { + foreach ($ids as $id) { + try { + $db->deleteDocument($collection, $id); + } catch (\Throwable) { + } + } + } + + $this->created = []; + } + + /** + * @return array + */ + public function getCreated(): array + { + return $this->created; + } +} diff --git a/src/Database/Seeder/Seeder.php b/src/Database/Seeder/Seeder.php new file mode 100644 index 000000000..9801b7c6d --- /dev/null +++ b/src/Database/Seeder/Seeder.php @@ -0,0 +1,18 @@ +> + */ + public function dependencies(): array + { + return []; + } + + abstract public function run(Database $db): void; +} diff --git a/src/Database/Seeder/SeederRunner.php b/src/Database/Seeder/SeederRunner.php new file mode 100644 index 000000000..f0ed52c0d --- /dev/null +++ b/src/Database/Seeder/SeederRunner.php @@ -0,0 +1,80 @@ +, Seeder> */ + private array $seeders = []; + + /** @var array */ + private array $executed = []; + + public function register(Seeder $seeder): void + { + $this->seeders[$seeder::class] = $seeder; + } + + public function run(Database $db): void + { + $this->executed = []; + $remaining = $this->seeders; + + while ($remaining !== []) { + $ready = []; + foreach ($remaining as $class => $seeder) { + $deps = $seeder->dependencies(); + $allDepsResolved = true; + foreach ($deps as $dep) { + if (! isset($this->executed[$dep])) { + $allDepsResolved = false; + break; + } + } + if ($allDepsResolved) { + $ready[$class] = $seeder; + } + } + + if ($ready === []) { + $unresolved = \implode(', ', \array_keys($remaining)); + throw new \RuntimeException("Circular dependency detected in seeders: {$unresolved}"); + } + + if (\count($ready) > 1) { + $tasks = []; + foreach ($ready as $class => $seeder) { + $tasks[] = function () use ($seeder, $db): void { + $seeder->run($db); + }; + } + Promise::map($tasks)->await(); + } else { + foreach ($ready as $seeder) { + $seeder->run($db); + } + } + + foreach ($ready as $class => $seeder) { + $this->executed[$class] = true; + unset($remaining[$class]); + } + } + } + + /** + * @return array + */ + public function getExecuted(): array + { + return $this->executed; + } + + public function reset(): void + { + $this->executed = []; + } +} diff --git a/tests/unit/Repository/RepositoryTest.php b/tests/unit/Repository/RepositoryTest.php new file mode 100644 index 000000000..c5f552c5f --- /dev/null +++ b/tests/unit/Repository/RepositoryTest.php @@ -0,0 +1,341 @@ +db = $this->createMock(Database::class); + $this->repo = new TestRepository($this->db); + } + + public function testFindByIdDelegatesToGetDocument(): void + { + $doc = new Document(['$id' => 'u1', 'name' => 'Alice']); + + $this->db->expects($this->once()) + ->method('getDocument') + ->with('users', 'u1') + ->willReturn($doc); + + $result = $this->repo->findById('u1'); + $this->assertSame($doc, $result); + } + + public function testFindAllDelegatesToFind(): void + { + $docs = [new Document(['$id' => 'u1']), new Document(['$id' => 'u2'])]; + + $this->db->expects($this->once()) + ->method('find') + ->with('users', []) + ->willReturn($docs); + + $result = $this->repo->findAll(); + $this->assertCount(2, $result); + } + + public function testFindAllWithQueriesPassesThem(): void + { + $queries = [Query::equal('status', ['active'])]; + + $this->db->expects($this->once()) + ->method('find') + ->with('users', $queries) + ->willReturn([]); + + $this->repo->findAll($queries); + } + + public function testFindOneByCreatesEqualQueryWithLimit1(): void + { + $doc = new Document(['$id' => 'u1', 'email' => 'alice@test.com']); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'users', + $this->callback(function (array $queries) { + $methods = array_map(static function (mixed $query): string { + return $query instanceof Query ? $query->getMethod()->value : ''; + }, $queries); + + return in_array('equal', $methods) && in_array('limit', $methods); + }) + ) + ->willReturn([$doc]); + + $result = $this->repo->findOneBy('email', 'alice@test.com'); + $this->assertEquals('u1', $result->getId()); + } + + public function testFindOneByReturnsEmptyDocumentWhenNoResults(): void + { + $this->db->method('find')->willReturn([]); + + $result = $this->repo->findOneBy('email', 'nonexistent@test.com'); + $this->assertTrue($result->isEmpty()); + } + + public function testCountDelegatesToCount(): void + { + $this->db->expects($this->once()) + ->method('count') + ->with('users', []) + ->willReturn(42); + + $this->assertEquals(42, $this->repo->count()); + } + + public function testCountWithQueries(): void + { + $queries = [Query::equal('status', ['active'])]; + + $this->db->expects($this->once()) + ->method('count') + ->with('users', $queries) + ->willReturn(10); + + $this->assertEquals(10, $this->repo->count($queries)); + } + + public function testCreateDelegatesToCreateDocument(): void + { + $doc = new Document(['name' => 'Alice']); + $created = new Document(['$id' => 'u1', 'name' => 'Alice']); + + $this->db->expects($this->once()) + ->method('createDocument') + ->with('users', $doc) + ->willReturn($created); + + $result = $this->repo->create($doc); + $this->assertEquals('u1', $result->getId()); + } + + public function testUpdateDelegatesToUpdateDocument(): void + { + $doc = new Document(['$id' => 'u1', 'name' => 'Bob']); + + $this->db->expects($this->once()) + ->method('updateDocument') + ->with('users', 'u1', $doc) + ->willReturn($doc); + + $result = $this->repo->update('u1', $doc); + $this->assertEquals('Bob', $result->getAttribute('name')); + } + + public function testDeleteDelegatesToDeleteDocument(): void + { + $this->db->expects($this->once()) + ->method('deleteDocument') + ->with('users', 'u1') + ->willReturn(true); + + $this->assertTrue($this->repo->delete('u1')); + } + + public function testMatchingAppliesSpecificationQueries(): void + { + $spec = new ActiveSpecification(); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'users', + $this->callback(function (array $queries) { + $query = $queries[0] ?? null; + + return count($queries) === 1 + && $query instanceof Query + && $query->getMethod()->value === 'equal'; + }) + ) + ->willReturn([]); + + $this->repo->matching($spec); + } + + public function testCompositeSpecificationAndMergesQueries(): void + { + $activeSpec = new ActiveSpecification(); + $adminSpec = new AdminSpecification(); + + $composite = $activeSpec->and($adminSpec); + $queries = $composite->toQueries(); + + $this->assertCount(2, $queries); + $attributes = array_map(fn (Query $q) => $q->getAttribute(), $queries); + $this->assertContains('status', $attributes); + $this->assertContains('role', $attributes); + } + + public function testCompositeSpecificationOrCreatesOrQueries(): void + { + $activeSpec = new ActiveSpecification(); + $adminSpec = new AdminSpecification(); + + $composite = $activeSpec->or($adminSpec); + $queries = $composite->toQueries(); + + $this->assertCount(1, $queries); + $this->assertSame(Method::Or, $queries[0]->getMethod()); + $values = $queries[0]->getValues(); + $this->assertCount(2, $values); + $attributes = []; + foreach ($values as $value) { + $this->assertInstanceOf(Query::class, $value); + $attributes[] = $value->getAttribute(); + } + $this->assertContains('status', $attributes); + $this->assertContains('role', $attributes); + } + + public function testSpecificationAndCreatesComposite(): void + { + $spec1 = new ActiveSpecification(); + $spec2 = new AdminSpecification(); + + $composite = $spec1->and($spec2); + $this->assertCount(2, $composite->toQueries()); + } + + public function testSpecificationOrCreatesComposite(): void + { + $spec1 = new ActiveSpecification(); + $spec2 = new AdminSpecification(); + + $composite = $spec1->or($spec2); + $this->assertCount(1, $composite->toQueries()); + } + + public function testCustomSpecificationImplementingInterface(): void + { + $spec = new ActiveSpecification(); + $queries = $spec->toQueries(); + + $this->assertCount(1, $queries); + $this->assertEquals('status', $queries[0]->getAttribute()); + } + + public function testMatchingWithBaseQueriesMergesBoth(): void + { + $spec = new ActiveSpecification(); + $baseQueries = [Query::orderAsc('name')]; + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'users', + $this->callback(function (array $queries) { + return count($queries) === 2; + }) + ) + ->willReturn([]); + + $this->repo->matching($spec, $baseQueries); + } + + public function testFindOneByHandlesArrayValue(): void + { + $this->db->expects($this->once()) + ->method('find') + ->with( + 'users', + $this->callback(function (array $queries) { + $query = $queries[0] ?? null; + + return $query instanceof Query && $query->getValues() === ['admin', 'editor']; + }) + ) + ->willReturn([]); + + $this->repo->findOneBy('role', ['admin', 'editor']); + } + + public function testCompositeSpecificationAndCanChainFurther(): void + { + $spec1 = new ActiveSpecification(); + $spec2 = new AdminSpecification(); + $spec3 = new ActiveSpecification(); + + $composite = $spec1->and($spec2)->and($spec3); + $queries = $composite->toQueries(); + + $this->assertGreaterThanOrEqual(3, count($queries)); + } + + public function testCompositeSpecificationOrCanChainFurther(): void + { + $spec1 = new ActiveSpecification(); + $spec2 = new AdminSpecification(); + $spec3 = new ActiveSpecification(); + + $composite = $spec1->or($spec2)->or($spec3); + $queries = $composite->toQueries(); + + $this->assertNotEmpty($queries); + } +} diff --git a/tests/unit/Repository/ScopeTest.php b/tests/unit/Repository/ScopeTest.php new file mode 100644 index 000000000..79d193cc8 --- /dev/null +++ b/tests/unit/Repository/ScopeTest.php @@ -0,0 +1,325 @@ +tenantId])]; + } +} + +class PriceSpec implements Specification +{ + public function __construct(private int $maxPrice) + { + } + + public function toQueries(): array + { + return [Query::lessThanEqual('price', $this->maxPrice)]; + } + + public function and(Specification $other): Specification + { + return new CompositeSpecification([$this, $other], 'and'); + } + + public function or(Specification $other): Specification + { + return new CompositeSpecification([$this, $other], 'or'); + } +} + +class ScopeTest extends TestCase +{ + protected Database&MockObject $db; + + protected ScopedRepository $repo; + + protected function setUp(): void + { + $this->db = $this->createMock(Database::class); + $this->repo = new ScopedRepository($this->db); + } + + public function testAddScopeAddsScope(): void + { + $scope = new ActiveScope(); + $this->repo->addScope($scope); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'products', + $this->callback(function (array $queries) { + $query = $queries[0] ?? null; + + return count($queries) === 1 + && $query instanceof Query + && $query->getAttribute() === 'active'; + }) + ) + ->willReturn([]); + + $this->repo->findAll(); + } + + public function testFindAllAppliesGlobalScopes(): void + { + $this->repo->addScope(new ActiveScope()); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'products', + $this->callback(function (array $queries) { + $attrs = []; + foreach ($queries as $query) { + if ($query instanceof Query) { + $attrs[] = $query->getAttribute(); + } + } + + return in_array('active', $attrs); + }) + ) + ->willReturn([new Document(['$id' => 'p1'])]); + + $results = $this->repo->findAll(); + $this->assertCount(1, $results); + } + + public function testFindOneByAppliesGlobalScopes(): void + { + $this->repo->addScope(new ActiveScope()); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'products', + $this->callback(function (array $queries) { + $attrs = []; + foreach ($queries as $query) { + if ($query instanceof Query) { + $attrs[] = $query->getAttribute(); + } + } + + return in_array('active', $attrs) && in_array('name', $attrs); + }) + ) + ->willReturn([new Document(['$id' => 'p1', 'name' => 'Widget'])]); + + $result = $this->repo->findOneBy('name', 'Widget'); + $this->assertEquals('p1', $result->getId()); + } + + public function testCountAppliesGlobalScopes(): void + { + $this->repo->addScope(new ActiveScope()); + + $this->db->expects($this->once()) + ->method('count') + ->with( + 'products', + $this->callback(function (array $queries) { + $attrs = []; + foreach ($queries as $query) { + if ($query instanceof Query) { + $attrs[] = $query->getAttribute(); + } + } + + return in_array('active', $attrs); + }) + ) + ->willReturn(5); + + $this->assertEquals(5, $this->repo->count()); + } + + public function testWithoutScopesBypassesGlobalScopes(): void + { + $this->repo->addScope(new ActiveScope()); + + $this->db->expects($this->once()) + ->method('find') + ->with('products', []) + ->willReturn([new Document(['$id' => 'p1']), new Document(['$id' => 'p2'])]); + + $results = $this->repo->withoutScopes(); + $this->assertCount(2, $results); + } + + public function testClearScopesRemovesAllScopes(): void + { + $this->repo->addScope(new ActiveScope()); + $this->repo->addScope(new TenantScope('t1')); + + $this->repo->clearScopes(); + + $this->db->expects($this->once()) + ->method('find') + ->with('products', []) + ->willReturn([]); + + $this->repo->findAll(); + } + + public function testMultipleScopesMergeQueries(): void + { + $this->repo->addScope(new ActiveScope()); + $this->repo->addScope(new TenantScope('t1')); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'products', + $this->callback(function (array $queries) { + $attrs = []; + foreach ($queries as $query) { + if ($query instanceof Query) { + $attrs[] = $query->getAttribute(); + } + } + + return in_array('active', $attrs) && in_array('tenantId', $attrs); + }) + ) + ->willReturn([]); + + $this->repo->findAll(); + } + + public function testMatchingCombinesScopesWithSpecification(): void + { + $this->repo->addScope(new ActiveScope()); + + $spec = new PriceSpec(100); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'products', + $this->callback(function (array $queries) { + $attrs = []; + foreach ($queries as $query) { + if ($query instanceof Query) { + $attrs[] = $query->getAttribute(); + } + } + + return in_array('active', $attrs) && in_array('price', $attrs); + }) + ) + ->willReturn([]); + + $this->repo->matching($spec); + } + + public function testScopesAppliedWithExplicitQueries(): void + { + $this->repo->addScope(new ActiveScope()); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'products', + $this->callback(function (array $queries) { + return count($queries) === 2; + }) + ) + ->willReturn([]); + + $this->repo->findAll([Query::orderAsc('name')]); + } + + public function testWithoutScopesPassesCustomQueries(): void + { + $this->repo->addScope(new ActiveScope()); + + $customQueries = [Query::equal('category', ['electronics'])]; + + $this->db->expects($this->once()) + ->method('find') + ->with('products', $customQueries) + ->willReturn([]); + + $this->repo->withoutScopes($customQueries); + } + + public function testCountWithScopesAndExplicitQueries(): void + { + $this->repo->addScope(new TenantScope('t2')); + + $this->db->expects($this->once()) + ->method('count') + ->with( + 'products', + $this->callback(function (array $queries) { + return count($queries) === 2; + }) + ) + ->willReturn(3); + + $this->assertEquals(3, $this->repo->count([Query::equal('status', ['published'])])); + } + + public function testClearScopesThenAddNewScope(): void + { + $this->repo->addScope(new ActiveScope()); + $this->repo->clearScopes(); + $this->repo->addScope(new TenantScope('t3')); + + $this->db->expects($this->once()) + ->method('find') + ->with( + 'products', + $this->callback(function (array $queries) { + $attrs = []; + foreach ($queries as $query) { + if ($query instanceof Query) { + $attrs[] = $query->getAttribute(); + } + } + + return in_array('tenantId', $attrs) && ! in_array('active', $attrs); + }) + ) + ->willReturn([]); + + $this->repo->findAll(); + } +} diff --git a/tests/unit/Seeder/FactoryTest.php b/tests/unit/Seeder/FactoryTest.php new file mode 100644 index 000000000..95a7d875b --- /dev/null +++ b/tests/unit/Seeder/FactoryTest.php @@ -0,0 +1,88 @@ +define('users', function (Generator $generator) use (&$used) { + $used = $generator; + + return ['name' => 'Injected']; + }); + + $doc = $factory->make('users'); + + $this->assertSame($faker, $used); + $this->assertSame('Injected', $doc->getAttribute('name')); + } + + public function testDefineAndMake(): void + { + $factory = new Factory(); + $factory->define('users', function (Generator $faker) { + return [ + 'name' => $faker->name(), + 'email' => $faker->email(), + 'age' => $faker->numberBetween(18, 65), + ]; + }); + + $doc = $factory->make('users'); + + $this->assertNotEmpty($doc->getAttribute('name')); + $this->assertNotEmpty($doc->getAttribute('email')); + $this->assertGreaterThanOrEqual(18, $doc->getAttribute('age')); + } + + public function testMakeWithOverrides(): void + { + $factory = new Factory(); + $factory->define('users', function (Generator $faker) { + return [ + 'name' => $faker->name(), + 'email' => $faker->email(), + ]; + }); + + $doc = $factory->make('users', ['name' => 'Override Name']); + + $this->assertEquals('Override Name', $doc->getAttribute('name')); + } + + public function testMakeMany(): void + { + $factory = new Factory(); + $factory->define('users', function (Generator $faker) { + return [ + 'name' => $faker->name(), + ]; + }); + + $docs = $factory->makeMany('users', 5); + + $this->assertCount(5, $docs); + foreach ($docs as $doc) { + $this->assertNotEmpty($doc->getAttribute('name')); + } + } + + public function testUndefinedCollectionThrows(): void + { + $factory = new Factory(); + + $this->expectException(\RuntimeException::class); + $factory->make('nonexistent'); + } + +} diff --git a/tests/unit/Seeder/FixtureTest.php b/tests/unit/Seeder/FixtureTest.php new file mode 100644 index 000000000..f72c53832 --- /dev/null +++ b/tests/unit/Seeder/FixtureTest.php @@ -0,0 +1,157 @@ +db = $this->createMock(Database::class); + $this->fixture = new Fixture(); + } + + public function testLoadSingleDocumentUsesCreateDocument(): void + { + $this->db->expects($this->once()) + ->method('createDocument') + ->with('users', $this->isInstanceOf(Document::class)) + ->willReturn(new Document(['$id' => 'u1', 'name' => 'Alice'])); + + $this->fixture->load($this->db, 'users', [ + ['name' => 'Alice'], + ]); + + $this->assertCount(1, $this->fixture->getCreated()); + $this->assertEquals('u1', $this->fixture->getCreated()[0]['id']); + } + + public function testLoadMultipleDocumentsUsesCreateDocuments(): void + { + $this->db->expects($this->once()) + ->method('createDocuments') + ->willReturnCallback(function (string $collection, array $docs, int $batch, ?callable $onNext) { + foreach ($docs as $i => $doc) { + $created = new Document(['$id' => 'u' . ($i + 1)]); + if ($onNext) { + $onNext($created); + } + } + + return \count($docs); + }); + + $this->fixture->load($this->db, 'users', [ + ['name' => 'Alice'], + ['name' => 'Bob'], + ]); + + $created = $this->fixture->getCreated(); + $this->assertCount(2, $created); + $this->assertEquals('u1', $created[0]['id']); + $this->assertEquals('u2', $created[1]['id']); + } + + public function testGetCreatedReturnsAllTrackedEntries(): void + { + $this->db->method('createDocument') + ->willReturnOnConsecutiveCalls( + new Document(['$id' => 'doc1']), + new Document(['$id' => 'doc2']), + ); + + $this->fixture->load($this->db, 'users', [['name' => 'A']]); + $this->fixture->load($this->db, 'posts', [['title' => 'B']]); + + $created = $this->fixture->getCreated(); + $this->assertCount(2, $created); + $this->assertEquals('users', $created[0]['collection']); + $this->assertEquals('posts', $created[1]['collection']); + } + + public function testCleanupDeletesDocumentsIndividually(): void + { + $this->db->method('createDocument') + ->willReturn(new Document(['$id' => 'u1'])); + + $this->db->expects($this->once()) + ->method('deleteDocument') + ->with('users', 'u1') + ->willReturn(true); + + $this->fixture->load($this->db, 'users', [['name' => 'A']]); + $this->fixture->cleanup($this->db); + + $this->assertEmpty($this->fixture->getCreated()); + } + + public function testCleanupHandlesDeleteErrors(): void + { + $this->db->method('createDocument') + ->willReturn(new Document(['$id' => 'u1'])); + $this->db->method('deleteDocument') + ->willThrowException(new \RuntimeException('Delete failed')); + + $this->fixture->load($this->db, 'users', [['name' => 'A']]); + $this->fixture->cleanup($this->db); + + $this->assertEmpty($this->fixture->getCreated()); + } + + public function testLoadWithEmptyArray(): void + { + $this->db->expects($this->never())->method('createDocument'); + $this->db->expects($this->never())->method('createDocuments'); + + $this->fixture->load($this->db, 'users', []); + $this->assertEmpty($this->fixture->getCreated()); + } + + public function testCleanupWithNoCreatedDocuments(): void + { + $this->db->expects($this->never())->method('deleteDocument'); + $this->fixture->cleanup($this->db); + $this->assertEmpty($this->fixture->getCreated()); + } + + public function testMultipleCleanupCallsAreIdempotent(): void + { + $this->db->method('createDocument') + ->willReturn(new Document(['$id' => 'u1'])); + $this->db->expects($this->once())->method('deleteDocument') + ->with('users', 'u1') + ->willReturn(true); + + $this->fixture->load($this->db, 'users', [['name' => 'A']]); + $this->fixture->cleanup($this->db); + $this->fixture->cleanup($this->db); + } + + public function testLoadWithMultipleCollections(): void + { + $this->db->method('createDocument') + ->willReturnOnConsecutiveCalls( + new Document(['$id' => 'u1']), + new Document(['$id' => 'p1']), + ); + + $this->fixture->load($this->db, 'users', [['name' => 'Alice']]); + $this->fixture->load($this->db, 'posts', [['title' => 'Hello']]); + + $created = $this->fixture->getCreated(); + $this->assertCount(2, $created); + $this->assertEquals('users', $created[0]['collection']); + $this->assertEquals('posts', $created[1]['collection']); + } +} diff --git a/tests/unit/Seeder/SeederRunnerTest.php b/tests/unit/Seeder/SeederRunnerTest.php new file mode 100644 index 000000000..23f2d6565 --- /dev/null +++ b/tests/unit/Seeder/SeederRunnerTest.php @@ -0,0 +1,130 @@ + */ + public array $order; + + /** + * @param list $order + */ + public function __construct(array &$order) + { + $this->order = &$order; + } + + public function run(Database $db): void + { + $this->order[] = 'A'; + } + }; + + $seederB = new class ($order, $seederA::class) extends Seeder { + /** @var list */ + public array $order; + + /** @var class-string */ + private string $depClass; + + /** + * @param list $order + * @param class-string $depClass + */ + public function __construct(array &$order, string $depClass) + { + $this->order = &$order; + $this->depClass = $depClass; + } + + public function dependencies(): array + { + return [$this->depClass]; + } + + public function run(Database $db): void + { + $this->order[] = 'B'; + } + }; + + $runner = new SeederRunner(); + $runner->register($seederA); + $runner->register($seederB); + + $db = self::createStub(Database::class); + $runner->run($db); + + $this->assertEquals(['A', 'B'], $order); + $this->assertEquals(['A', 'B'], $seederA->order); + $this->assertEquals(['A', 'B'], $seederB->order); + } + + public function testDoesNotRunSameSeederTwice(): void + { + $count = 0; + + $seeder = new class ($count) extends Seeder { + private int $count; + + public function __construct(int &$count) + { + $this->count = &$count; + } + + public function run(Database $db): void + { + $this->count++; + } + }; + + $runner = new SeederRunner(); + $runner->register($seeder); + + $db = self::createStub(Database::class); + $runner->run($db); + + $this->assertEquals(1, $count); + $this->assertArrayHasKey($seeder::class, $runner->getExecuted()); + } + + public function testResetAllowsRerun(): void + { + $count = 0; + + $seeder = new class ($count) extends Seeder { + private int $count; + + public function __construct(int &$count) + { + $this->count = &$count; + } + + public function run(Database $db): void + { + $this->count++; + } + }; + + $runner = new SeederRunner(); + $runner->register($seeder); + + $db = self::createStub(Database::class); + $runner->run($db); + $runner->reset(); + $runner->run($db); + + $this->assertEquals(2, $count); + } +}