-
Notifications
You must be signed in to change notification settings - Fork 58
feat(repository): add the repository and seeder layers #948
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat-query-lib
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Repository; | ||
|
|
||
| use Utopia\Database\Query; | ||
|
|
||
| class CompositeSpecification implements Specification | ||
| { | ||
| /** | ||
| * @param array<Specification> $specs | ||
| */ | ||
| public function __construct( | ||
| private array $specs, | ||
| private string $operator = 'and', | ||
| ) { | ||
| } | ||
|
|
||
| /** | ||
| * @return array<Query> | ||
| */ | ||
| 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'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Repository; | ||
|
|
||
| use Utopia\Database\Database; | ||
| use Utopia\Database\Document; | ||
| use Utopia\Database\Query; | ||
|
|
||
| abstract class Repository | ||
| { | ||
| /** @var array<Scope> */ | ||
| 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<Query> $queries | ||
| * @return array<Query> | ||
| */ | ||
| protected function applyScopes(array $queries): array | ||
| { | ||
| foreach ($this->globalScopes as $scope) { | ||
| $queries = \array_merge($queries, $scope->apply()); | ||
| } | ||
|
|
||
| return $queries; | ||
| } | ||
|
|
||
| /** | ||
| * @param array<Query> $queries | ||
| * @return array<Document> | ||
| */ | ||
| 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<Query> $queries | ||
| * @return array<Document> | ||
| */ | ||
| 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<Query> $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<Query> $baseQueries | ||
| * @return array<Document> | ||
| */ | ||
| public function matching(Specification $spec, array $baseQueries = []): array | ||
| { | ||
| return $this->findAll(\array_merge($baseQueries, $spec->toQueries())); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Repository; | ||
|
|
||
| use Utopia\Database\Query; | ||
|
|
||
| interface Scope | ||
| { | ||
| /** | ||
| * @return array<Query> | ||
| */ | ||
| public function apply(): array; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Repository; | ||
|
|
||
| use Utopia\Database\Query; | ||
|
|
||
| interface Specification | ||
| { | ||
| /** | ||
| * @return array<Query> | ||
| */ | ||
| public function toQueries(): array; | ||
|
|
||
| public function and(Specification $other): Specification; | ||
|
|
||
| public function or(Specification $other): Specification; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Seeder; | ||
|
|
||
| use Faker\Factory as FakerFactory; | ||
| use Faker\Generator; | ||
| use Utopia\Database\Database; | ||
| use Utopia\Database\Document; | ||
|
|
||
| class Factory | ||
| { | ||
| private Generator $faker; | ||
|
|
||
| /** @var array<string, FactoryDefinition> */ | ||
| 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<string, mixed> $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<string, mixed> $data */ | ||
| $data = ($this->definitions[$collection]->callback)($this->faker); | ||
|
|
||
| return new Document(\array_merge($data, $overrides)); | ||
| } | ||
|
|
||
| /** | ||
| * @param array<string, mixed> $overrides | ||
| * @return array<Document> | ||
| */ | ||
| 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<string, mixed> $overrides | ||
| */ | ||
| public function create(string $collection, Database $db, array $overrides = []): Document | ||
| { | ||
| return $db->createDocument($collection, $this->make($collection, $overrides)); | ||
| } | ||
|
|
||
| /** | ||
| * @param array<string, mixed> $overrides | ||
| * @return array<Document> | ||
| */ | ||
| 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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Seeder; | ||
|
|
||
| class FactoryDefinition | ||
| { | ||
| /** @var callable */ | ||
| public $callback; | ||
|
|
||
| public function __construct(callable $callback) | ||
| { | ||
| $this->callback = $callback; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Seeder; | ||
|
|
||
| use Utopia\Database\Database; | ||
| use Utopia\Database\Document; | ||
|
|
||
| class Fixture | ||
| { | ||
| /** @var array<array{collection: string, id: string}> */ | ||
| private array $created = []; | ||
|
|
||
| /** | ||
| * @param array<array<string, mixed>> $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) { | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When Knowledge Base Used: Document lifecycle and representation Prompt To Fix With AIThis is a comment left during a code review.
Path: src/Database/Seeder/Fixture.php
Line: 53
Comment:
**Failed cleanup loses tracking**
When `deleteDocument()` throws during cleanup, the exception is suppressed and the entire tracking list is still cleared. The undeleted record remains in the database while `getCreated()` reports nothing outstanding, preventing cleanup from being retried and contaminating later tests or development data.
**Knowledge Base Used:** [Document lifecycle and representation](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/database/-/docs/document-lifecycle.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| $this->created = []; | ||
| } | ||
|
|
||
| /** | ||
| * @return array<array{collection: string, id: string}> | ||
| */ | ||
| public function getCreated(): array | ||
| { | ||
| return $this->created; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <?php | ||
|
|
||
| namespace Utopia\Database\Seeder; | ||
|
|
||
| use Utopia\Database\Database; | ||
|
|
||
| abstract class Seeder | ||
| { | ||
| /** | ||
| * @return array<class-string<Seeder>> | ||
| */ | ||
| public function dependencies(): array | ||
| { | ||
| return []; | ||
| } | ||
|
|
||
| abstract public function run(Database $db): void; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a repository registers a tenant, soft-delete, or other business scope,
findById()calls the database directly without applying it;update()anddelete()have the same bypass. An out-of-scope document can therefore be returned, updated, or deleted.Knowledge Base Used:
Prompt To Fix With AI