From 2a6143b09267267fb396eb4bcd953dd704b1900c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 27 Aug 2026 13:20:35 +1200 Subject: [PATCH] feat(repository): add the repository and seeder layers Splits out of the query-lib migration, which had carried these along with it. Repository gives a typed collection facade -- Repository, Scope, Specification and CompositeSpecification -- so a caller composes reusable query fragments instead of assembling Query arrays at each call site. Seeder gives Factory, FactoryDefinition, Fixture and SeederRunner for building test and development data. Stacked on feat-query-lib rather than main: both are written against the Attribute, Collection and Query value objects that migration introduces. Nothing consumes either one yet, in this library or in appwrite or cloud, so neither has been exercised against a real engine. The tests here are unit tests over composition and the factory definitions. Co-Authored-By: Claude Opus 5 --- .../Repository/CompositeSpecification.php | 55 +++ src/Database/Repository/Repository.php | 116 ++++++ src/Database/Repository/Scope.php | 13 + src/Database/Repository/Specification.php | 17 + src/Database/Seeder/Factory.php | 92 +++++ src/Database/Seeder/FactoryDefinition.php | 14 + src/Database/Seeder/Fixture.php | 64 ++++ src/Database/Seeder/Seeder.php | 18 + src/Database/Seeder/SeederRunner.php | 80 ++++ tests/unit/Repository/RepositoryTest.php | 341 ++++++++++++++++++ tests/unit/Repository/ScopeTest.php | 325 +++++++++++++++++ tests/unit/Seeder/FactoryTest.php | 88 +++++ tests/unit/Seeder/FixtureTest.php | 157 ++++++++ tests/unit/Seeder/SeederRunnerTest.php | 130 +++++++ 14 files changed, 1510 insertions(+) create mode 100644 src/Database/Repository/CompositeSpecification.php create mode 100644 src/Database/Repository/Repository.php create mode 100644 src/Database/Repository/Scope.php create mode 100644 src/Database/Repository/Specification.php create mode 100644 src/Database/Seeder/Factory.php create mode 100644 src/Database/Seeder/FactoryDefinition.php create mode 100644 src/Database/Seeder/Fixture.php create mode 100644 src/Database/Seeder/Seeder.php create mode 100644 src/Database/Seeder/SeederRunner.php create mode 100644 tests/unit/Repository/RepositoryTest.php create mode 100644 tests/unit/Repository/ScopeTest.php create mode 100644 tests/unit/Seeder/FactoryTest.php create mode 100644 tests/unit/Seeder/FixtureTest.php create mode 100644 tests/unit/Seeder/SeederRunnerTest.php diff --git a/src/Database/Repository/CompositeSpecification.php b/src/Database/Repository/CompositeSpecification.php new file mode 100644 index 0000000000..1cbe90200f --- /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 0000000000..1147582f5a --- /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 0000000000..13df2b8487 --- /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 0000000000..d9babad56a --- /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 0000000000..35091979fa --- /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 0000000000..ce2fa6fc3b --- /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 0000000000..636425a57c --- /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 0000000000..9801b7c6df --- /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 0000000000..f0ed52c0de --- /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 0000000000..c5f552c5fc --- /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 0000000000..79d193cc8c --- /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 0000000000..95a7d875b6 --- /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 0000000000..f72c538326 --- /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 0000000000..23f2d6565c --- /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); + } +}