Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/Database/Repository/CompositeSpecification.php
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');
}
}
116 changes: 116 additions & 0 deletions src/Database/Repository/Repository.php
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);
}
Comment on lines +53 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Global scopes are bypassed

When a repository registers a tenant, soft-delete, or other business scope, findById() calls the database directly without applying it; update() and delete() have the same bypass. An out-of-scope document can therefore be returned, updated, or deleted.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/Repository/Repository.php
Line: 53-56

Comment:
**Global scopes are bypassed**

When a repository registers a tenant, soft-delete, or other business scope, `findById()` calls the database directly without applying it; `update()` and `delete()` have the same bypass. An out-of-scope document can therefore be returned, updated, or deleted.

**Knowledge Base Used:**
- [Query construction and execution](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/database/-/docs/query-execution.md)
- [Database orchestration](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/database/-/docs/database-orchestration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex


/**
* @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()));
}
}
13 changes: 13 additions & 0 deletions src/Database/Repository/Scope.php
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;
}
17 changes: 17 additions & 0 deletions src/Database/Repository/Specification.php
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;
}
92 changes: 92 additions & 0 deletions src/Database/Seeder/Factory.php
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;
}
}
14 changes: 14 additions & 0 deletions src/Database/Seeder/FactoryDefinition.php
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;
}
}
64 changes: 64 additions & 0 deletions src/Database/Seeder/Fixture.php
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) {
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Prompt To Fix With AI
This 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.

Fix in Claude Code Fix in Codex

$this->created = [];
}

/**
* @return array<array{collection: string, id: string}>
*/
public function getCreated(): array
{
return $this->created;
}
}
18 changes: 18 additions & 0 deletions src/Database/Seeder/Seeder.php
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;
}
Loading
Loading