Skip to content
Merged
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## 8.1.3

8.1.3 adds a hook that rewrites the queries of a read. It changes nothing for a database without one; see
[Upgrading from 8.1.2 to 8.1.3](UPGRADE.md#upgrading-from-812-to-813).

### Added

- `Hook\Queries::rewrite(Event $event, Collection $collection, array $queries): array` rewrites the queries of a
read, registered with `addHook()` and removed with `removeHook()`. It is given the queries one read was given and
the definition of the collection being read, once each query is checked to be a `Query` and before the read checks
its queries any further: before it validates, parses or authorizes them or resolves their joins. The queries it
returns replace the read's queries for the rest of the read, the query-cache key included. It runs for
`getDocument()` (`Event::DocumentRead`), `find()` and so `findOne()`, `cursor()` and `resolveCursor()`
(`Event::DocumentFind`), `count()` (`Event::DocumentCount`), `sum()` (`Event::DocumentSum`) and `aggregate()`
(`Event::DocumentAggregate`). Hooks run in registration order, each given the previous one's output. Throwing
refuses the read, with the exception reaching the caller unchanged before anything is read. It also runs on the
reads such a read makes itself: relationship population, the reads that resolve queries on relationship attributes,
`findOne()` through `find()`, each `cursor()` page and `resolveCursor()`'s read of the joined row it pairs with the
document. So it must return the same queries when given its own output; it runs whether or not events are silenced.
It never runs for reads of `Database::METADATA`, for the `query()`, `mutate()` and `from()` builders, or for writes
and the reads they make themselves. The writes are `createDocument()`, `createDocuments()`, `updateDocument()`,
`updateDocuments()`, `upsertDocument()`, `upsertDocuments()`, `increaseDocumentAttribute()`,
`decreaseDocumentAttribute()`, `deleteDocument()` and `deleteDocuments()`, and the `authorizeCreate()`,
`authorizeUpdate()` and `authorizeGrants()` checks of a later write; their own reads load the documents they change,
read back the ones they wrote, populate the relationships of the documents they return, and check, link and cascade
related documents, so a hook that hides rows never hides them from a write's permission and relationship checks. A
read the caller's own code makes while a write runs, such as in an `onNext` callback, runs it. With such a hook
registered, `cursor()` resolves the collection and rewrites its queries when it is called, so a missing collection
or a refusal throws then instead of when the generator is iterated. A `Mirror` runs it once on each of its own reads
and never on its destination's. One that is also a `Hook\Lifecycle` is registered on the source too, as every
lifecycle hook is, so it also rewrites the reads made on the source directly, except the reads the mirror's writes
make there.

## 8.1.2

8.1.2 removes public API that nothing outside the library needed, replaces the opt-in emulation of 7.4.1's fractional
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,30 @@ $database->silent(fn () => $database->ping(), ['audit-log']);

A `Utopia\Database\Hook\Transform` rewrites SQL statements before they run, and a `Utopia\Database\Hook\Decorator` modifies the documents that reads and writes return. Both are registered with `addHook()` too, and `removeHook()` unregisters a hook. `Event\DispatcherHook` forwards events to listeners registered per event class and to a PSR-14 dispatcher.

A `Utopia\Database\Hook\Queries` rewrites the queries of `getDocument()`, `find()`, `findOne()`, `cursor()`, `resolveCursor()`, `count()`, `sum()` and `aggregate()` before the read checks its queries; throwing refuses the read. It also runs on the reads those reads make themselves, such as relationship population, so it must return the same queries when given its own output. Writes, and the reads they make themselves, never run it:

```php
use Utopia\Database\Collection;
use Utopia\Database\Event;
use Utopia\Database\Hook\Queries;
use Utopia\Database\Query;

final class PublicNames implements Queries
{
public function rewrite(Event $event, Collection $collection, array $queries): array
{
return \array_map(
static fn (Query $query): Query => $query->getMethod()->isJoin() && $query->getAttribute() === 'public-movies'
? (clone $query)->setAttribute('movies')
: $query,
$queries,
);
}
}

$database->addHook(new PublicNames());
```

### Database Methods

```php
Expand Down
10 changes: 10 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# Upgrading from 8.1.2 to 8.1.3

8.1.3 is a patch release and needs no code change. It adds `Hook\Queries`, which rewrites the queries of a read
before the read checks its queries; a database without one reads as in 8.1.2. To use it, implement
`rewrite(Event $event, Collection $collection, array $queries): array` and register the hook with `addHook()`. Return
the queries the read should use, cloning any query you change, and return the same queries when given your own output,
as the hook also runs on the reads a read makes itself, such as relationship population. Throw to refuse the read.
Writes, and the reads they make themselves, never run it. See [Hooks](README.md#hooks) for an example and
[CHANGELOG.md](CHANGELOG.md#813) for which reads it covers.

# Upgrading from 8.1.1 to 8.1.2

8.1.2 is a patch release. It removes public API that nothing outside the library needed, replaces the opt-in emulation
Expand Down
43 changes: 42 additions & 1 deletion src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ class Database
*/
protected array $nestedHooks = [];

/**
* @var list<Hook\Queries>
*/
protected array $queryHooks = [];

/** @var Value<bool>|null Whether every lifecycle hook is silenced. */
private ?Value $silenced = null;

Expand Down Expand Up @@ -275,6 +280,9 @@ class Database
/** @var Value<bool>|null */
private ?Value $grantChecking = null;

/** @var Value<bool>|null Whether {@see Hook\Queries} hooks rewrite the queries of reads. */
private ?Value $rewriting = null;

protected ?Relationships $relationshipHook = null;

protected bool $dropUnknownAttributes = false;
Expand Down Expand Up @@ -1355,6 +1363,22 @@ public function skipValidation(callable $callback): mixed
return $this->withValidation(false, $callback);
}

/**
* Run the callback with no {@see Hook\Queries} hook rewriting the reads it makes, for the calling coroutine and
* the coroutines it starts. A write makes its own reads under it, so it sees the documents as they are stored.
*
* @internal
*
* @template T
*
* @param callable(): T $callback
* @return T
*/
public function skipQueryHooks(callable $callback): mixed
{
return $this->rewriting()->with(false, $callback);
}

/**
* Register a hook into the database pipeline.
*
Expand All @@ -1364,6 +1388,7 @@ public function skipValidation(callable $callback): mixed
* - {@see Hook\Decorator} — document transformation on read/write results
* - {@see Hook\Relationships} — relationship resolution and mutation
* - {@see Hook\Nested} — preparation of each nested related document before a write relates it, run in registration order
* - {@see Hook\Queries} — rewriting of a read's queries before it validates, resolves or authorizes them, run in registration order
* - {@see Hook\Write} — row-level write interception (permissions, tenant)
* - {@see Hook\Transform} — raw SQL transformation before execution
* - {@see Invalidator} — the query cache invalidation, replacing the one setQueryCache() made
Expand All @@ -1380,6 +1405,7 @@ public function addHook(\Utopia\Query\Hook $hook): static
&& ! $hook instanceof Hook\Decorator
&& ! $hook instanceof Relationships
&& ! $hook instanceof Hook\Nested
&& ! $hook instanceof Hook\Queries
&& ! $hook instanceof Hook\Write
&& ! $hook instanceof Transform
) {
Expand Down Expand Up @@ -1410,6 +1436,10 @@ public function addHook(\Utopia\Query\Hook $hook): static
$this->nestedHooks[] = $hook;
}

if ($hook instanceof Hook\Queries) {
$this->queryHooks[] = $hook;
}

if ($hook instanceof Hook\Write) {
$this->adapter->addWriteHook($hook);
}
Expand Down Expand Up @@ -1439,6 +1469,7 @@ public function removeHook(\Utopia\Query\Hook|string $hook): static
$this->lifecycleHooks = \array_values(\array_filter($this->lifecycleHooks, $kept));
$this->decorators = \array_values(\array_filter($this->decorators, $kept));
$this->nestedHooks = \array_values(\array_filter($this->nestedHooks, $kept));
$this->queryHooks = \array_values(\array_filter($this->queryHooks, $kept));

if ($this->invalidator !== null && ! $kept($this->invalidator)) {
$this->invalidator = null;
Expand Down Expand Up @@ -1606,6 +1637,7 @@ public function snapshot(): Snapshot
ignoreDuplicates: $this->ignoringDuplicates()->get(),
requestTimestamp: $this->requestTimestamp()->get(),
checkGrants: $this->grantChecking()->get(),
rewriting: $this->rewriting()->get(),
);
}

Expand Down Expand Up @@ -1646,7 +1678,8 @@ public function withSnapshot(Snapshot $snapshot, callable $callback): mixed
*/
private function withToggles(Snapshot $snapshot, callable $callback): mixed
{
$checked = fn (): mixed => $this->grantChecking()->with($snapshot->checkGrants, $callback);
$rewritten = fn (): mixed => $this->rewriting()->with($snapshot->rewriting, $callback);
$checked = fn (): mixed => $this->grantChecking()->with($snapshot->checkGrants, $rewritten);
$timestamped = fn (): mixed => $this->requestTimestamp()->with($snapshot->requestTimestamp, $checked);
$deduplicated = fn (): mixed => $this->ignoringDuplicates()->with($snapshot->ignoreDuplicates, $timestamped);
$sequenced = fn (): mixed => $this->sequencePreservation()->with($snapshot->preserveSequence, $deduplicated);
Expand Down Expand Up @@ -1734,6 +1767,14 @@ private function grantChecking(): Value
return $this->grantChecking ??= new Value(false);
}

/**
* @return Value<bool>
*/
private function rewriting(): Value
{
return $this->rewriting ??= new Value(true);
}

protected function isIgnoringDuplicates(): bool
{
return $this->ignoringDuplicates()->get();
Expand Down
53 changes: 53 additions & 0 deletions src/Database/Hook/Queries.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Utopia\Database\Hook;

use Utopia\Database\Collection;
use Utopia\Database\Database;
use Utopia\Database\Event;
use Utopia\Database\Query;
use Utopia\Query\Hook;

/**
* Hook for rewriting the queries of a read before the read uses them.
*
* It is given the queries one read was given and the definition of the collection being read. It runs once per read,
* after the collection is resolved and each query is checked to be a {@see Query}, and before the read validates,
* parses or authorizes its queries or resolves their joins. The queries it returns replace the read's queries for the
* rest of the read, the query-cache key included. getDocument() runs it with {@see Event::DocumentRead}; find(), and
* so findOne(), cursor() and resolveCursor() with {@see Event::DocumentFind}; count() with {@see Event::DocumentCount};
* sum() with {@see Event::DocumentSum}; and aggregate() with {@see Event::DocumentAggregate}. Hooks run in the order
* they were registered, each given the queries the previous one returned.
*
* Throwing refuses the read: the exception reaches the caller unchanged, before anything is read. With a hook
* registered, cursor() resolves the collection and rewrites its queries when it is called, so a missing collection or
* a refusal throws then, not when the generator is iterated.
*
* It runs on every read through those methods, the reads a read makes itself included: relationship population and
* the reads that resolve queries on relationship attributes, findOne() through find(), each page of cursor(), and
* resolveCursor()'s read of the joined row it pairs with the document. So it must return the same queries when given
* its own output. It runs whether or not events are silenced.
*
* It never runs for reads of {@see Database::METADATA}, for the query(), mutate() and from() builders, or for writes
* and the reads they make themselves. The writes are createDocument(), createDocuments(), updateDocument(),
* updateDocuments(), upsertDocument(), upsertDocuments(), increaseDocumentAttribute(), decreaseDocumentAttribute(),
* deleteDocument() and deleteDocuments(), and the authorizeCreate(), authorizeUpdate() and authorizeGrants() checks of
* a later write. Their own reads load the documents they change, read back the ones they wrote, populate the
* relationships of the documents they return, and check, link and cascade related documents. A read the caller's own
* code makes while a write runs, as an onNext callback can, runs it.
*
* The given queries belong to the caller, so a hook that changes one returns a clone of it. It must not modify the
* collection, which can be a cached definition. A hook that needs the database implements {@see Attachable}.
*/
interface Queries extends Hook
{
/**
* Rewrite the queries of a read before the read uses them.
*
* @param Event $event The event of the read
* @param Collection $collection The definition of the collection being read
* @param array<Query> $queries The queries the read was given, or the previous hook returned
* @return array<Query> The queries the read uses
*/
public function rewrite(Event $event, Collection $collection, array $queries): array;
}
Loading
Loading