8.1.4 is a patch release and needs no code change. Database::deleteDocuments() keeps its signature and now deletes
only the documents that still match its filters when they are deleted; see CHANGELOG.md.
Adapter::deleteDocuments() keeps its signature. A batch with filters reaches the adapter through the new
Adapter::deleteDocumentsMatching(), which calls deleteDocuments() with the filters readable through
$this->deletionFilters(), so an override of deleteDocuments() in a subclass of a bundled adapter still runs and,
when it calls its parent, deletes only the rows that match. A custom adapter that extends Adapter directly deletes
every row it is handed, as in 8.1.3; to keep a document another write changed after it was found, delete only the
rows that match every query of deletionFilters() (filter queries, as find() is handed them), keep the permission
rows of a row kept, and return the number of rows deleted.
The count deleteDocuments() returns is a hint: a lower count than the rows handed makes Database::deleteDocuments()
check which documents are still stored, and a higher one is ignored, so a custom adapter that counts differently still
works.
deleteDocuments() can now throw Exception\Contention when documents keep changing between the check and the delete
of a batch. Inside a transaction begun with startTransaction(), roll it back and run it again: on MySQL and MariaDB
the retry in the savepoint rereads the same snapshot, and a MongoDB replica set has no savepoint, so the partial
delete of the batch stays in the transaction until it is rolled back.
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 for an example and
CHANGELOG.md for which reads it covers.
8.1.2 is a patch release. It removes public API that nothing outside the library needed, replaces the opt-in emulation
of 7.4.1's fractional arithmetic on integer attributes with one rounding rule that always applies, moves the update
models into the namespace of the model they update, and a Pool answers with the hostname it is given.
CHANGELOG.md lists every change.
Each removal has a replacement:
| Removed | Use instead |
|---|---|
Database::read() |
find() for documents, aggregate() for aggregate or groupBy() queries; see below |
Database::rawQuery(string $query, array $bindings = []): array |
Database::query(new \Utopia\Query\Builder\Statement($query, $bindings)), inside getAuthorization()->skip() as before. It returns a list of Document. The adapter's Feature\RawQuery::rawQuery() stays. |
Database::getRelationshipHook() (internal) |
Keep the Hook\Relationships you pass to addHook() |
Database::getMaxIndexLength(), Database::getMaxVarcharLength() |
$database->getAdapter()->limits()->indexLength, ->varchar |
Database::getMinDateTime(), Database::getMaxDateTime() |
$database->getAdapter()->limits()->minDateTime, ->maxDateTime; clone the DateTime before modifying it |
Database::getCacheWriterTimeout() |
The value given to setCacheWriterTimeout(); without one, Cache\Scope::WRITER_TIMEOUT (3600 seconds) |
Database::setRoundFractions(), Database::withRoundFractions(), Database::isRoundingFractions() and the Mirror overrides |
None: delete the calls. Integer rounding always applies. |
Adapter::arithmetic() and its adapter overrides |
None: rounding happens once in Database, before the adapter is called |
Utopia\Database\Arithmetic, Utopia\Database\Arithmetic\Rounding |
None |
The trailing $fractions argument of Validator\Operator, Validator\Structure and Validator\PartialStructure |
Drop the argument |
The trailing $roundFractions argument of the State\Snapshot constructor |
Drop the argument |
Event\Permission\Created, Event\Permission\Read, Event\Permission\Deleted |
None: Database never dispatched them. Event::PermissionsCreate, PermissionsRead and PermissionsDelete stay as the labels of the statements a permissions write runs, for transforms and timeouts, and their domain() is null. |
Adapter\SQL\Hook\Permission\AllowNullUid |
Adapter\SQL\Hook\Column\AllowNull, which takes the same (Filter $filter, string $column, string $quoteCharacter) arguments |
Utopia\Database\AttributeUpdate, CollectionUpdate, RelationshipUpdate |
Utopia\Database\Attribute\Update, Collection\Update, Relationship\Update; see Update models |
Call find() for documents and aggregate() for aggregate or groupBy() queries. A list that serves both picks one
with ParsedQuery::isAggregation():
$rows = Query::groupByType($queries)->isAggregation()
? \array_map(static fn (array $row): Document => new Document($row), $database->aggregate('posts', $queries))
: $database->find('posts', $queries, $forPermission);aggregate() reads under the read permission only. read() refused aggregate queries under another permission with
Exception\Query; a caller that relied on that refusal checks $forPermission itself.
The update models moved into the namespace of the model they update. Their constructors and fields are unchanged, so only the imports change:
| 8.1.1 | 8.1.2 |
|---|---|
Utopia\Database\AttributeUpdate |
Utopia\Database\Attribute\Update |
Utopia\Database\CollectionUpdate |
Utopia\Database\Collection\Update |
Utopia\Database\RelationshipUpdate |
Utopia\Database\Relationship\Update |
A file that uses more than one of them imports each under an alias, which keeps the call sites unchanged:
use Utopia\Database\Attribute\Update as AttributeUpdate;
use Utopia\Database\Relationship\Update as RelationshipUpdate;On a single-valued integer or big integer attribute:
| Value | Rule | Example |
|---|---|---|
The change of increaseDocumentAttribute() or decreaseDocumentAttribute() |
Rounded half away from zero | 1.5 adds 2; 2.5 subtracts 3 |
The amount of Operator::increment() or Operator::decrement() |
Rounded half away from zero | increment(-2.5) subtracts 3 |
A max, or the limit of increment() |
Floored | 101.9 is 101 |
A min, or the limit of decrement() |
Ceiled | 99.1 is 100 |
The operators are rounded in updateDocument(), updateDocuments(), upsertDocument() and upsertDocuments().
Rounding happens once, in PHP, before validation, so every adapter stores the same result. Decimal text is any text
is_numeric() accepts without an exponent: an optional + or -, digits before or after the point or both, and
surrounding whitespace, such as ' +.5' or '1.'. It is rounded exactly at any size: '9007199254740993.5' adds
9007199254740994. Text with an exponent, such as '1.5e2', and values that are not finite are refused.
A change that rounds to 0, such as 0.4, changes nothing, and its max or min is not checked:
increaseDocumentAttribute()anddecreaseDocumentAttribute()write nothing, keep$updatedAtand fire no event.- An
increment()ordecrement()is dropped from the update.updateDocument()treats the attribute as given its stored value, so with nothing else changed it keeps$updatedAt, as for any update that changes nothing.updateDocuments()given nothing else returns 0 and fires no event. An upsert of a stored document counts it as no change; an upsert that creates the document stores whatincrement(0)ordecrement(0)would. - An amount given as the integer 0 is not rounded and behaves as in 8.1.1.
Refused with Exception\Structure, as in 8.1.1 without setRoundFractions(true): a fractional amount or limit of
multiply(), divide(), modulo() or power() on an integer attribute, and a fractional item added to an integer
array. Float and double attributes are unchanged.
If you relied on fractions being refused, check the value before you call. If you used setRoundFractions(true):
- Delete the
setRoundFractions()andwithRoundFractions()calls and anyisRoundingFractions()checks. The rule above applies on every adapter instead of each engine's 7.4.1 rounding. - A counter returns the stored whole number, not the exact fractional result as a float.
- A fractional
maxorminis floored or ceiled before the comparison, instead of being compared as given. - The stored value is no longer read locked to resolve an operator, and
updateDocuments()sends one update for all rows again. - A fractional item added to an integer array is refused with
Exception\Structureinstead of being cut toward zero. - A fractional amount or limit of
multiply(),divide(),modulo()orpower()on an integer attribute is refused withException\Structureinstead of being resolved against the stored value.
Pool::hostname()returns the hostname given toPool::setHostname()when it is not empty. Otherwise it asks the pooled adapter, as 8.1.1 always did. AReadWritePoolfollows a latersetHostname()too, instead of keeping the hostname it answered first.Database::getHostname()and the document and query cache keys follow it, so a pool given a hostname reads and writes cache entries under that hostname from 8.1.2 on.Pool::getPool(): Utopia\Pools\Poolreturns the pool of adapters the handle borrows from. A subclass that exposed the protected$poolproperty, or overrode the hostname, for either purpose can be removed.
8.1 is a minor release. Every change in it is additive or opt-in, with one exception: it runs
joins on a one-to-many relationship that 8.0.3 refused. With no Hook\Nested
hook registered and with setCheckGrants() off, which is the default, a Database behaves exactly as in 8.0.3 apart
from that join and the integer rounding of 8.1.2.
CHANGELOG.md lists every new signature.
- Preparing nested related documents
- Grant check on related documents
- Cursor from an ID
- Documents or aggregates
- Joins on a one-to-many relationship
- Spatial support behind a pool
State\Snapshot
The new toggle follows the 8.0 toggle idiom (see Configuration toggles). It is scoped to
the calling coroutine and the coroutines it starts. withSnapshot() carries it into relationship writes and
other work that runs in another coroutine.
| Toggle | Set | Read | Scope |
|---|---|---|---|
| Grant check | setCheckGrants(bool $check): static |
isCheckingGrants(): bool |
withCheckGrants(bool $check, callable $callback): mixed |
Additive. An application that rewrites nested related documents before they are written, such as filling in an
attribute the caller left out, implements a Utopia\Database\Hook\Nested hook:
interface Nested extends Hook
{
public function prepare(Document $collection, Document $document): Document;
}Register it with $database->addHook($hook). Remove it with removeHook($hook), or remove every one with
removeHook(Nested::class).
- What it is given. Each related document that
createDocument(),createDocuments(),updateDocument(),upsertDocument(),upsertDocuments(),authorizeCreate()orauthorizeUpdate()is given as a document (aDocumentor an associative array), at every depth the write relates.$collectionis the collection the related document belongs to. Since 8.1.1,authorizeGrants()gives it the same documents, only while it checks grants. - When it runs. Before the write checks the document's grants and relates it, a parent before its children. Several hooks run in the order they were registered, each given the document the previous one returned.
- What it returns. It is given a copy, never the caller's document. The write checks and relates the document it
returns, so links and junction rows use the IDs it returns. In
createDocuments(),upsertDocument()andupsertDocuments(), the prepared related documents replace the relationship values of the caller's top-level documents in place, as those writes prepare the documents they are given. - What it is not given. The top-level document, a related document given as an ID string, and anything from
updateDocuments(). A string stays a reference to the document stored under that ID, as in 8.0. - Running more than once. One logical write can run it more than once:
authorizeCreate()orauthorizeUpdate()and then the write, or a retried transaction. A pre-check prepares a copy, and the write prepares the document again. So the hook must return the same document when it is given its own output. - Conditions. It runs while the relationship hook is attached and enabled, so not inside
skipRelationships(), whether or not events are silenced. Its exceptions abort the write before anything is written. - New documents. A related document whose returned ID is not stored is new. Relating it, in the write and in
authorizeCreate()andauthorizeUpdate(), needs create permission on its collection, and with the grant check on, all of its permissions are checked. - Malformed values. While a hook is registered or grants are checked, a related document given as an associative
array that is not a valid document throws
Exception\Relationship(Invalid relationship value. <reason>) before anything is written. - Mirror.
Mirror::addHook()registers it on the mirror itself, for its pre-checks, and on the source, never on the destination. The destination replicates what the source wrote, as the hook returned it there.removeHook()removes it from both.
use Utopia\Database\Document;
use Utopia\Database\Hook\Nested;
final readonly class DefaultStatus implements Nested
{
public function __construct(private string $status)
{
}
public function prepare(Document $collection, Document $document): Document
{
if ($collection->getId() === 'books' && $document->getAttribute('status') === null) {
$document->setAttribute('status', $this->status);
}
return $document;
}
}
$database->addHook(new DefaultStatus('draft'));
$database->createDocument('authors', new Document([
'$id' => 'ada',
'name' => 'Ada',
'books' => [
['$id' => 'notes', 'title' => 'Notes'], // stored with status 'draft'
'sketches', // a reference to a stored book, not given to the hook
],
]));Opt-in. By default 8.1 never compares the permissions a write grants with the roles the caller holds, as in 8.0.
With setCheckGrants(true), the check covers related documents written through a parent: every related document
given as a document to createDocument(), createDocuments(), updateDocument(), upsertDocument(),
upsertDocuments(), authorizeCreate(), authorizeUpdate() or authorizeGrants(), as deep as the write relates
them, each as the Hook\Nested hooks return it. The document the write is given
is not checked. For each related document whose $permissions is not null:
- Malformed permissions throw
Exception\Structurewith theValidator\Permissionsdescription. - The permissions it adds must name roles the caller holds (
Authorization::hasRole()). Otherwise the write throwsException\Authorization(Permissions must be one of: (<the caller's roles>)). The added permissions arePermission::aggregate()of the sent permissions minus those the document already has. A document that is not stored yet, with an empty$idor an ID no stored document has, has none. Permissions sent back unchanged pass, and so does an empty list. - Permissions a related document inherits from its parent are never checked.
- Both refusals happen before anything is written.
The check is skipped while authorization is disabled ($authorization->skip() or setStatus(false)), and it runs only
while the relationship hook is attached and enabled. To write without it while authorization stays enabled, use
withCheckGrants(false, $callback).
A related document's current permissions are read whether or not the caller can read the document:
createDocument()andupdateDocument()read them locked, inside the write's transaction.createDocuments(),upsertDocument()andupsertDocuments()write related documents before the transaction of the documents that relate them. They read the permissions without a lock, so a permission change committed between the check and the related write is not seen.authorizeCreate()andauthorizeUpdate()take them from$resolve($collection, $id)when it returns a document, and from the stored document when it returnsnull. A document$resolvereturns empty (staged for deletion) is new.- A related document whose
$idaHook\Nestedhook assigned is new to this check: none of its permissions are read, locked or resolved, so all of them are checked.
Since 8.1.1, authorizeGrants(string $collection, Document $document, ?callable $resolve = null): void runs this check
alone, without writing anything. It judges the related documents of $document exactly as authorizeCreate() and
authorizeUpdate() do, after the Hook\Nested hooks and through $resolve, and throws the same exceptions, but it
checks no create or update permission. Call it first when an unheld grant must be reported even if the caller also
lacks permission on the parent, for example for a staged upsert that is not yet known to create or update:
$database->authorizeGrants('books', $book, $resolve);
if ($stored->isEmpty()) {
$database->authorizeCreate('books', $book, $resolve);
} else {
$database->authorizeUpdate('books', $stored, $book, $resolve);
}It does nothing, and reads nothing, unless grants are checked, authorization is enabled and the relationship hook is attached and enabled.
While grants are checked, updateDocuments() refuses a related document given as a document with
Exception\Relationship (Invalid relationship value. A bulk update takes related documents by ID while grants are checked.), because it is not checked there. Related documents given by ID still link.
Mirror checks on its source only. The destination replays what the source allowed with the check off, so a
destination that is behind never refuses it.
Because the current permissions are read regardless of the caller's read permission, the error can reveal one fact to
a caller who cannot read a related document: whether a permission it sends is already on that document. If it is, this
check passes and the relationship write refuses the document for its read or update reason instead. Both refusals are
Exception\Authorization. If that difference matters to you, answer every Exception\Authorization with the same
response.
Additive. Database::resolveCursor(string $collection, string $id, CursorDirection $direction, array $queries = []): Document returns the cursor a list pages from when a client names its last row by document ID. Pass the list's
$queries (its cursor queries are left out) and put the result in the list's cursorAfter() or cursorBefore().
- The document is read with authorization skipped, so a list can page past a document its caller cannot read.
- The cursor holds only
$id,$sequence,$collection, the values the list orders by and thealias.$idtie-breaksfind()adds for a join. An order on a joined attribute takes its value from the joined rows the caller can read that the list pairs with the document. A page after the document starts behind the last of those rows, and a page before it ends ahead of the first. With no such row, the value is null. - It returns an empty
Documentwhen no document has the ID. It throwsException\NotFoundfor a missing collection, andException\Queryfor invalid queries or joins.
The caller chooses the orders, and the values come from a document it may not be allowed to read. Never return the
cursor, or any value in it, to an end user. Only pass it to cursorAfter() or cursorBefore().
$cursor = $database->resolveCursor('posts', $lastId, CursorDirection::After, $queries);
if ($cursor->isEmpty()) {
// no such document
}
$page = $database->find('posts', [...$queries, Query::cursorAfter($cursor)]);No change. find() still refuses aggregate and groupBy() queries, as 8.0 decided; aggregate() runs them.
ParsedQuery::isAggregation(): bool (from Query::groupByType($queries)) tells which of the two a list is. 8.1.0 and
8.1.1 also had Database::read(), which 8.1.2 removes (see
Upgrading from 8.1.1 to 8.1.2).
Changed. The parent side of a one-to-many relationship holds no column: each child stores the parent's ID under the
relationship's two-way key. 8.0.3 refused a join condition whose left column is such an attribute
(Cannot join on virtual relationship attribute). 8.1 compares the main $id with the two-way key in the joined
collection instead.
// customers: Relationship::oneToMany(relatedCollection: 'orders', key: 'orders', twoWayKey: 'customer')
$rows = $database->find('customers', [
Query::leftJoin('orders', 'ord', [Query::on('orders', '$id')]), // runs as Query::on('$id', 'customer')
Query::select(['name', 'ord.amount']),
]);- The condition keeps its operator, and its right column becomes the two-way key. Other conditions of the join are
unchanged, and the caller's
Queryobjects are not modified. getDocument(),find(),findOne(),aggregate(),count(),sum()andresolveCursor()run it, socursorAfter()andcursorBefore()page over the joined rows.- Only an unqualified left column that is the parent side of a one-to-many with a two-way key is resolved. Every other relationship attribute joins, or is refused, exactly as in 8.0.3; so is a left column under a join's alias.
- The joined collection is read under the same permission and tenant rules as any join.
No change. 8.0.3 already answers correctly, and 8.1 adds tests for it. Ask
$adapter->hasFeature(Feature\Spatial::class) or $database->profile()->hasFeature(Feature\Spatial::class), never
instanceof Feature\Spatial. Pool and ReadWritePool do not implement the feature interfaces; they answer
hasFeature() for the adapter they pool. A Mirror's getAdapter() is its source's adapter, so it answers for the
source. Creating a point attribute fails exactly when hasFeature(Feature\Spatial::class) is false.
Additive. The State\Snapshot constructor takes one more trailing argument, bool $checkGrants = false, which
Database::snapshot() fills in. Code that builds a Snapshot itself still works; a snapshot built without it runs its
callback with the grant check off.
This guide lists the changes you may need to make when you move from utopia-php/database 7.x (last release 7.4.1)
to 8.0. CHANGELOG.md lists everything that is new in 8.0. If you built against the unreleased
feat-query-lib branch, also read Changes since the 8.0 pre-releases.
- Before you start
- Register the permission and relationship hooks
- Constants are now enums
- Namespaces
- Queries
- Schema: value objects
- Lifecycle events are hooks
- Relationships
- Documents
- Bulk writes and reads
- Configuration toggles
- Coroutines
- Errors
- Caches
- Filters
- Adapters
- Mirror
- Validators and helpers
- Removed unused public methods
- Changes since the 8.0 pre-releases
- Rules for features new in 8.0
- Known limitations
- PHP 8.5 or later is required, as for 7.x.
- Two new dependencies are installed with the library.
utopia-php/query0.7 provides the query, schema and builder types that 8.0 uses in its signatures (Utopia\Query\Method,Utopia\Query\Schema\ColumnType, ...).utopia-php/async0.2 is used byMirrorreplication and the relationship hook. ItsUtopia\Async\Serializer::unserialize()no longer decodes closure payloads: code of your own that calls it on closure payloads switches toSerializer::unserializeTrusted()(see utopia-php/async's UPGRADE.md). - Many constants became enums, and many signatures now take or return enum cases. PHP never treats an enum case as
equal to a string, so a comparison like
$query->getMethod() === 'equal'is now alwaysfalseand raises no error. Run PHPStan at level 4 or higher on your code after upgrading: it reports these comparisons. - Most API changes have a one-to-one replacement. Each table below lists the 7.x form and the 8.0 form; every removed name is listed with its replacement. 8.0 ships no aliases for removed names.
Document permissions and relationships are now hooks, and a Database registers neither on its own. Register both
right after you create the Database:
use Utopia\Database\Hook\Permissions;
use Utopia\Database\Hook\Relationships;
$database->addHook(new Permissions());
$database->addHook(new Relationships());addHook() throws Utopia\Database\Exception for a hook it does not recognise. A hook that implements
Hook\Attachable is given the database it is added to through attach(Database $database), so hooks are configured
in their constructor and never take the database there: new Relationships(bool $prepare = true),
new Tenancy(string $column = Storage::TENANT). A Relationships hook belongs to one database: adding it to a second
one throws, so give each database its own instance (or a clone). removeHook($hook) unregisters a hook instance, or every hook of a
class when given its class name.
Hook\Permissionswrites, moves and deletes the rows of each collection's permissions table (_perms) when a document's$permissionschange. What depends on those rows differs by engine:- MariaDB, MySQL and SQLite check document-level permissions in
find(),count()andsum()against these rows. Without the hook they write no new rows and neither revoke nor delete existing ones, so these reads keep following the rows written before (for example by 7.x): a permission removed withupdateDocument()still makes the document readable throughfind()whilegetDocument()refuses it, a deleted document's rows stay, and a document created again with the same id is readable by the roles the deleted one granted. A document whose permissions were written without the hook is returned byfind()only through a collection-level permission. - PostgreSQL checks the
_permissionscolumn of the document's own row, as in 7.x, so its reads follow the current$permissionswith or without the hook. The hook still maintains the_permsrows there. - MongoDB, Memory and Redis keep permissions with the document and do not need it.
- MariaDB, MySQL and SQLite check document-level permissions in
Hook\Relationshipspopulates related documents on reads and runs nested writes, theonDeleterules (Cascade,SetNull,Restrict) and the relationship permission checks. Without it, a read returns a relationship attribute's stored value (the related document's id) instead of the related document, a nested related document cannot be written, and deleting a document leaves the documents related to it unchanged: no cascade runs, no key is set to null andRestrictdoes not block the delete.
Every removed string constant maps to an enum case with the same backing value, except Database::VAR_BIGINT (see
Attribute types). Stored metadata and query strings do not change.
| 7.x | 8.0 |
|---|---|
VAR_STRING, VAR_VARCHAR, VAR_TEXT, VAR_MEDIUMTEXT, VAR_LONGTEXT, VAR_INTEGER, VAR_BOOLEAN, VAR_DATETIME, VAR_ID, VAR_OBJECT, VAR_VECTOR, VAR_RELATIONSHIP, VAR_POINT, VAR_LINESTRING, VAR_POLYGON |
Utopia\Query\Schema\ColumnType::String, Varchar, Text, MediumText, LongText, Integer, Boolean, Datetime, Id, Object, Vector, Relationship, Point, Linestring, Polygon |
VAR_UUID7 |
ColumnType::Uuid7. It names MongoDB's sequence id type (Database::getIdAttributeType()) and is not an attribute type: an attribute stored as uuid7 is refused (see Stored metadata) |
VAR_FLOAT ('double') |
ColumnType::Double. ColumnType::Float ('float') is a separate, new type |
VAR_BIGINT ('bigint') |
ColumnType::BigInteger, whose value is 'biginteger' (see Attribute types) |
STRING_TYPES |
No replacement: list the string cases (String, Varchar, Text, MediumText, LongText) |
SPATIAL_TYPES |
$attribute->isSpatial() on an Attribute |
ATTRIBUTE_FILTER_TYPES |
No replacement: the datetime(), point(), lineString(), polygon(), vector() and object() factories add their filter themselves. The built-in filter names are the Utopia\Database\Filter cases |
INTERNAL_ATTRIBUTES |
$database->internalAttributes(), a list of Attribute (tenant-aware) |
INSERT_BATCH_SIZE, DELETE_BATCH_SIZE |
Database::BATCH_SIZE (see Bulk writes and reads) |
INDEX_KEY, INDEX_UNIQUE, INDEX_FULLTEXT, INDEX_SPATIAL, INDEX_OBJECT, INDEX_TRIGRAM, INDEX_TTL, INDEX_HNSW_EUCLIDEAN, INDEX_HNSW_COSINE, INDEX_HNSW_DOT |
Utopia\Query\Schema\IndexType::Key, Unique, Fulltext, Spatial, Object, Trigram, Ttl, HnswEuclidean, HnswCosine, HnswDot |
ORDER_ASC, ORDER_DESC |
Utopia\Query\OrderDirection::Asc, Desc, for index orders and adapter order types alike |
ORDER_RANDOM |
OrderDirection::Random for queries (Query::orderRandom()). An index refuses it with Exception\Index |
PERMISSION_CREATE, PERMISSION_READ, PERMISSION_UPDATE, PERMISSION_DELETE, PERMISSION_WRITE |
Utopia\Database\PermissionType::Create, Read, Update, Delete, Write |
PERMISSIONS |
[PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete] |
RELATION_ONE_TO_ONE, RELATION_ONE_TO_MANY, RELATION_MANY_TO_ONE, RELATION_MANY_TO_MANY |
Utopia\Database\RelationshipType::OneToOne, OneToMany, ManyToOne, ManyToMany |
RELATION_MUTATE_CASCADE, RELATION_MUTATE_RESTRICT, RELATION_MUTATE_SET_NULL |
Utopia\Database\RelationshipDeleteAction::Cascade, Restrict, SetNull |
RELATION_SIDE_PARENT, RELATION_SIDE_CHILD |
Utopia\Database\RelationshipSide::Parent, Child |
CURSOR_AFTER, CURSOR_BEFORE |
Utopia\Query\CursorDirection::After, Before |
EVENT_* (all 33) |
Utopia\Database\Event cases: EVENT_DOCUMENT_CREATE is Event::DocumentCreate, EVENT_ALL is Event::All, and so on. The values are unchanged (Event::DocumentCreate->value === 'document_create'). New cases: DatabaseUpdate, DocumentUpsert, DocumentAggregate, AttributeRename, IndexesCreate |
COLLECTION (protected) |
Database::collectionDefinition(): Collection |
- The 48
TYPE_*constants are replaced byUtopia\Query\Methodcases with the same values. Most names map directly (TYPE_EQUALisMethod::Equal,TYPE_CURSOR_AFTERisMethod::CursorAfter); these four do not:TYPE_GREATERisMethod::GreaterThan,TYPE_GREATER_EQUALisMethod::GreaterThanEqual,TYPE_LESSERisMethod::LessThanandTYPE_LESSER_EQUALisMethod::LessThanEqual.Query::TYPE_ELEM_MATCHis removed. Query::TYPESis removed.Query::VECTOR_TYPESis replaced byMethod::isVector();Methodalso hasisFilter(),isSpatial(),isNested(),isAggregate()andisJoin().Query::LOGICAL_TYPESis public and holdsMethodcases.
- The
TYPE_*constants are replaced byUtopia\Database\OperatorTypecases with the same values (TYPE_INCREMENTisOperatorType::Increment,TYPE_ARRAY_APPENDisOperatorType::ArrayAppend, ...). Operator::TYPESis replaced byOperatorType::cases(). The protectedNUMERIC_TYPES,ARRAY_TYPES,STRING_TYPES,BOOLEAN_TYPESandDATE_TYPESare replaced byOperatorType::isNumeric(),isArray(),isString(),isBoolean()andisDate().
Document::SET_TYPE_ASSIGN, SET_TYPE_APPEND and SET_TYPE_PREPEND are replaced by
Utopia\Database\SetType::Assign, Append and Prepend.
Validator\Query\Select::INTERNAL_ATTRIBUTES(protected) is removed.$database->internalAttributes()returns the internal attributes asAttributevalue objects, including$tenantunder shared tables.Adapter\SQL::VECTOR_DISTANCE_COLUMN(protected) is replaced byUtopia\Database\Storage::DISTANCE.
These methods take or return enum cases where 7.x used the constants' strings:
| Method | Argument or return value |
|---|---|
Database::find(), cursor() |
PermissionType $forPermission = PermissionType::Read |
Database::setTimeout(), clearTimeout() |
Event $event = Event::All |
Database::getIdAttributeType() |
returns a ColumnType case (was a string) |
Document::setAttribute() |
SetType $type = SetType::Assign |
Document::getPermissionsByType() |
PermissionType|string $type: a string reads a type of your own, such as execute |
Query::getMethod() |
returns a Method case (see Queries) |
Operator::__construct(), setMethod() |
OperatorType $method |
Operator::getMethod() |
returns an OperatorType case |
Moved classes keep no alias under their 7.x name.
| 7.x | 8.0 |
|---|---|
Utopia\Database\Helpers\ID |
Utopia\Database\Id |
Utopia\Database\Helpers\Permission, Helpers\Role |
Utopia\Database\Permission, Utopia\Database\Role |
Utopia\Database\Mirroring\Filter |
Utopia\Database\Mirror\Filter; its init() is initialize() |
Validator\Attribute |
Validator\AttributeDefinition (see Validators and helpers) |
Validator\Index |
Validator\IndexDefinition |
Validator\Queries |
Validator\Queries\Base |
Validator\IndexedQueries |
Validator\Queries\Indexed |
Validator\ObjectValidator |
Validator\ObjectValue |
// 7.x
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
// 8.0
use Utopia\Database\Id;
use Utopia\Database\Permission;
use Utopia\Database\Role;
$permissions = [Permission::read(Role::user(Id::unique()))];Utopia\Database\Querynow extendsUtopia\Query\Query. Every 7.x method still exists, and the query wire format (Query::parse(), JSON) is unchanged.Query::getMethod()returns aUtopia\Query\Methodcase instead of a string, andOperator::getMethod()returns anOperatorTypecase. Compare with cases:$query->getMethod() === Method::Equal. A comparison with a string is alwaysfalseand raises no error (see Before you start).setMethod(),isMethod()and the constructor accept aMethodcase or its string value.Query::groupByType()returns aUtopia\Database\ParsedQueryobject instead of an array. It extends the query library'sParsedQuerywithorderAttributesandorderTypes(OrderDirectioncases); read the groups from its properties. A cursor that is not a document throwsException\Query.Query::groupForDatabase()is removed.Query::cursorAfter()andQuery::cursorBefore()takearray|objectinstead ofDocument. Pass the cursor document, as before.Validator\Query\Cursoraccepts aDocumentor a document id and refuses an array.- Aggregates and joins carry their alias in a property: read it with
getAlias().toArray()writes it as"alias", andparse()reads it there as well as from the 7.x positions. Query::parse(),parseQuery()andparseQueries()take a trailingbool $allowRaw = false. Raw queries are refused unless it istrue, so leave itfalsefor input you do not control.Query::orderAsc()andQuery::orderDesc()take an optional?Utopia\Query\NullsPosition $nulls.- The factory methods return
staticinstead ofQuery. Query::contains()is deprecated, and every call raisesE_USER_DEPRECATED. UsecontainsString()for substring matching on string attributes andcontainsAny()for array attributes. Queries whose method iscontains(for example parsed from JSON) keep working.Query::DEFAULT_ALIAS, the alias of the queried collection in generated SQL, is nowtable_main(it wasmain). Raw SQL fragments or selections that namedmain.<column>have to useQuery::DEFAULT_ALIAS. A join may not use it as its alias, in any letter case.notContainson an array attribute excludes documents whose array is NULL (or missing, on MongoDB) on every adapter. SQLite and MongoDB used to include them. To match those documents as well, combine it withQuery::isNull()inQuery::or().- On SQLite,
contains,containsAny,containsAllandnotContainson array attributes compare elements by value (strings, integers, doubles, booleans). - On MongoDB,
startsWith()andendsWith()are anchored:startsWith('foo')no longer returnsbarfoo, andendsWith('foo')no longer returnsfoobar. Both remain case-sensitive on MongoDB (on MariaDB and MySQL they follow the column collation). A caller that relied on the substring behaviour should usecontainsString(). orderRandom()is validated againstCapability::OrderRandom. On MongoDB, which does not support it,find()throwsUtopia\Database\Exception\Query(Random order is not supported by this adapter) instead of a genericException.distinct()needsCapability::Aggregations. On Memory, Redis and MongoDB,find()refuses it withUtopia\Database\Exception\Query(Distinct queries are not supported by this adapter) also when validation is skipped; MongoDB returned duplicate rows then.- A filter on a path into an object attribute (
meta.address.city) takes keys ofa-z,A-Z,0-9,_and-only. The query validator refuses any other key withUtopia\Database\Exception\Queryon every adapter (7.x refused such keys on PostgreSQL only), and on PostgreSQL the query is refused also when validation is skipped. - On PostgreSQL, an exact search (
search('title', '"foo bar"')) matches the words as an adjacent phrase, as on MariaDB, MySQL and SQLite, andnotSearch()with an exact term excludes only that phrase. In 7.x PostgreSQL matched every word in any order, and so did 8.0.1, which reverted the phrase match by mistake; 8.0.0 and 8.0.2 match the phrase. To match every word in any order, pass asearch()for each word. A term without quotes still matches any of its words. containsAll()on an attribute that is not an array matches as in 7.x: on MariaDB, MySQL and PostgreSQL each value is aLIKE(ILIKE) pattern on the whole value and a document matching any of them is returned; on MongoDB it is$all. UsecontainsString()for substrings.- A read whose orders include
orderRandom()ignores its cursor, as in 7.x.
The schema methods take value objects instead of long lists of scalar arguments, and the create and update methods
return what they stored. Attribute, Index and Relationship are final readonly classes with a private
constructor: build them with their factories. Collection stays a Document, because it is the metadata row, and is
built with Collection::create(). Updates take AttributeUpdate, CollectionUpdate and RelationshipUpdate.
// 7.x
$database->createCollection('movies', $attributes, $indexes, [Permission::read(Role::any())], true);
$database->createAttribute('movies', 'year', Database::VAR_INTEGER, 0, true);
$database->createIndex('movies', 'idx_year', Database::INDEX_KEY, ['year'], [], [Database::ORDER_DESC]);
$database->createRelationship('movies', 'reviews', Database::RELATION_ONE_TO_MANY, true, 'reviews', 'movie', Database::RELATION_MUTATE_CASCADE);
$database->updateAttributeRequired('movies', 'year', false);
$database->updateRelationship('movies', 'reviews', onDelete: Database::RELATION_MUTATE_SET_NULL);
$database->updateCollection('movies', [Permission::read(Role::any())], false);
// 8.0
$database->createCollection(Collection::create(
id: 'movies',
attributes: $attributes,
indexes: $indexes,
permissions: [Permission::read(Role::any())],
documentSecurity: true,
));
$database->createAttribute('movies', Attribute::integer('year', required: true));
$database->createIndex('movies', Index::key('idx_year', ['year'], orders: [OrderDirection::Desc]));
$database->createRelationship('movies', Relationship::oneToMany(
'reviews',
key: 'reviews',
twoWay: true,
twoWayKey: 'movie',
onDelete: RelationshipDeleteAction::Cascade,
));
$database->updateAttribute('movies', 'year', new AttributeUpdate(required: false));
$database->updateRelationship('movies', 'reviews', new RelationshipUpdate(onDelete: RelationshipDeleteAction::SetNull));
$database->updateCollection('movies', new CollectionUpdate(permissions: [Permission::read(Role::any())], documentSecurity: false));Every schema method names its collection argument $collection, and its attribute, index or relationship argument
$key.
| 7.x | 8.0 |
|---|---|
createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document |
createCollection(Collection $collection): Collection |
updateCollection(string $id, array $permissions, bool $documentSecurity): Document |
updateCollection(string $collection, CollectionUpdate $update): Collection. A null field keeps the stored value |
getCollection(string $id): Document, an empty Document when the collection is missing |
getCollection(string $collection): Collection, which throws Exception\NotFound when it is missing |
| — | findCollection(string $collection): ?Collection, null when the collection is missing. A miss fires no event |
deleteCollection(string $id): bool |
deleteCollection(string $collection): void |
exists(?string $database = null, ?string $collection = null): bool |
exists(?string $database = null): bool for a database, collectionExists(string $collection, ?string $database = null): bool for a collection |
createAttribute(string $collection, string $id, string $type, int $size, bool $required, mixed $default = null, bool $signed = true, bool $array = false, ?string $format = null, array $formatOptions = [], array $filters = []): bool |
createAttribute(string $collection, Attribute $attribute): Attribute |
createAttributes(string $collection, array $attributes): bool, with attribute arrays |
createAttributes(string $collection, array $attributes): array, a list of Attribute in and out |
updateAttribute(string $collection, string $id, ?string $type = null, ?int $size = null, ?bool $required = null, mixed $default = null, ?bool $signed = null, ?bool $array = null, ?string $format = null, ?array $formatOptions = null, ?array $filters = null, ?string $newKey = null): Document |
updateAttribute(string $collection, string $key, AttributeUpdate $update): Attribute |
updateAttributeRequired(), updateAttributeFormat(), updateAttributeFormatOptions(), updateAttributeFilters(), updateAttributeDefault() |
updateAttribute() with the AttributeUpdate field of the same name (required, format, filters, default) |
renameAttribute(string $collection, string $old, string $new): bool |
renameAttribute(string $collection, string $old, string $new): void. AttributeUpdate(key: ...) renames as well |
deleteAttribute(string $collection, string $id): bool |
deleteAttribute(string $collection, string $key): void |
checkAttribute(Document $collection, Document $attribute): bool |
checkAttribute(string $collection, Attribute $attribute): bool |
createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 1): bool |
createIndex(string $collection, Index $index): Index |
| — | createIndexes(string $collection, array $indexes): array. It validates every index first, creates each one, then writes the definition once |
renameIndex(string $collection, string $old, string $new): bool |
renameIndex(string $collection, string $old, string $new): void |
deleteIndex(string $collection, string $id): bool |
deleteIndex(string $collection, string $key): void |
createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, ?string $id = null, ?string $twoWayKey = null, string $onDelete = Database::RELATION_MUTATE_RESTRICT): bool |
createRelationship(string $collection, Relationship $relationship): Relationship, with both keys resolved |
updateRelationship(string $collection, string $id, ?string $newKey = null, ?string $newTwoWayKey = null, ?bool $twoWay = null, ?string $onDelete = null): bool |
updateRelationship(string $collection, string $key, RelationshipUpdate $update): Relationship. It works from either side |
deleteRelationship(string $collection, string $id): bool |
deleteRelationship(string $collection, string $key): void |
getInternalAttributes(): array |
internalAttributes(): array, a list of Attribute |
getSchemaAttributes(string $collection): array, getSchemaIndexes(string $collection): array, lists of Document |
Lists of Schema\Column and Schema\Index (see Schema introspection) |
getIdAttributeType(): string |
getIdAttributeType(): ColumnType |
| — | update(string $database, string $new): bool renames a database (see Renaming a database) |
create(), exists(), update() and delete() on a database keep returning bool.
One factory per type. Each takes only the arguments its type uses:
| Factory | Notes |
|---|---|
string($key, int $size = Database::LENGTH_KEY, ...), varchar(...) |
Also required, default, array, format, filters |
text($key, ?int $size = null, ...), mediumText(...), longText(...) |
size: null means the engine's maximum for the type |
integer($key, ..., bool $signed = true, bool $array = false, IntegerWidth $width = IntegerWidth::Bits32, ...) |
A 7.x integer of size 8 or more is width: IntegerWidth::Bits64 |
bigInteger(...), float(...), double(...) |
signed, array, format, filters |
boolean($key, bool $required = false, bool|array|null $default = null, bool $array = false, array $filters = []) |
|
datetime($key, bool $required = false, string|array|null $default = null, bool $array = false) |
Adds the datetime filter |
point($key, ...), lineString($key, ...), polygon($key, ...) |
Never arrays; add their filter |
vector($key, int $dimensions, ...) |
The size is the dimension count |
object($key, ...), id($key, ...) |
|
relationship($key, Relationship $relationship, RelationshipSide $side) |
Built for you by createRelationship() |
// 7.x
$database->createAttribute('movies', 'rating', Database::VAR_INTEGER, 8, false, 0, false, false, 'range', ['min' => 0, 'max' => 10], ['encrypt']);
// 8.0
$database->createAttribute('movies', Attribute::integer(
'rating',
default: 0,
signed: false,
width: IntegerWidth::Bits64,
format: new Format('range', ['min' => 0, 'max' => 10]),
filters: ['encrypt'],
));-
Properties, not getters. Read
$attribute->key,type(ColumnType),size(?int,nullwhere the type has no size),required,default,signed,array,format(?Format, withnameandoptions),filters(list<string>),relationship(?Relationship) andside(?RelationshipSide). A 7.x attribute'sformatandformatOptionsare oneFormat.filterstakesUtopia\Database\Filtercases or names. -
Helpers.
width(): ?IntegerWidth(integers only),resolvedSize(): int,isSpatial(),isNumeric(),isInteger(),bounds(): ?NumericBounds, the staticAttribute::isRelationship(Document $attribute), andapply(AttributeUpdate $update)andwithFilters(array $filters), which return a changed copy.Attribute::availableTypes(Adapter\Profile $profile)lists the types an adapter supports, out ofAttribute::TYPES. -
Storage.
Attribute::fromDocument()reads a stored attribute document andtoDocument()writes one with the same keys.Attribute::fromArray()reads an array.Attribute::typeFromStored()andAttribute::storedType()convert betweenColumnTypecases and stored type strings (see Attribute types). -
Library fields only.
statusandoptionsare not attribute fields any more:fromDocument()ignores both, except a relationship's options, which becomerelationshipandside. Keep application fields such as astatusin your own documents. -
Normalised values. The factories,
apply(),withFilters(),fromDocument()andfromArray()keep each type's invariants: a type's own filter is always kept,sizeisnullwhere the factory takes no size, and spatial, object and vector attributes are never arrays. A stored 7.x attribute reads as the attribute its factory builds: a datetime storedsigned: truereads unsigned, an integer stored with size 4 reads asBits32with no size, and a spatial attribute stored with a size or an array flag is read without them. Stored values are normalised per type:Factory Stored values datetime()signed: false,filters: ['datetime']point(),lineString(),polygon()array: false,filters: [<type>]vector()size: <dimensions>,array: false,filters: ['vector']object()array: false,filters: ['object']integer()size0 (Bits32) or 8 (Bits64)boolean(),double(),id()size: 0string(),varchar(),text(),mediumText(),longText()signed: truerelationship()the 7.x optionsshape plussideCode that compares stored attribute documents field by field (for example to detect drift) has to compare these normalised values: compare the attributes
fromDocument()reads, not the raw documents, which keep the 7.x values until the collection's attributes are next written. -
64-bit integers.
bounds()gives aBits64integer the bounds[PHP_INT_MIN, PHP_INT_MAX]signed and[0, PHP_INT_MAX]unsigned; 7.x applied the 32-bit range.increaseDocumentAttribute(),decreaseDocumentAttribute()and the numeric operators on a size-8 integer accept values past 2147483647.
| Factory | Notes |
|---|---|
key($key, array $attributes, array $lengths = [], array $orders = []), unique(...) |
Orders are ?OrderDirection |
fulltext($key, array $attributes), trigram($key, array $attributes) |
No lengths or orders |
spatial($key, string $attribute, ?OrderDirection $order = null), object($key, string $attribute) |
One attribute |
hnswEuclidean($key, string $attribute), hnswCosine(...), hnswDot(...) |
One vector attribute |
ttl($key, string $attribute, int $ttl) |
ttl of at least 1 |
// 7.x
$database->createIndex('movies', 'expiry', Database::INDEX_TTL, ['expiresAt'], [], [], 3600);
// 8.0
$database->createIndex('movies', Index::ttl('expiry', 'expiresAt', 3600));- Read
$index->key,type(IndexType),attributes,lengths(list<?int>),orders(list<?OrderDirection>) andttl(?int, TTL indexes only).withKey(),withLengths()andwithOrders()return a changed copy. OrderDirection::Randomand attlbelow 1 throwException\Index.- Fulltext and TTL indexes store no
orders, fulltext indexes store nolengths, and an index other than TTL no longer storesttl: 1.Index::fromDocument()reads both shapes, dropping thelengthsandordersa 7.x fulltext or TTL index stored, the 7.x'asc'/'desc'orders, and the legacy typeindexas a key index.
Relationship::oneToOne(), oneToMany(), manyToOne() and manyToMany() take
(string $relatedCollection, ?string $key = null, bool $twoWay = false, ?string $twoWayKey = null, RelationshipDeleteAction $onDelete = RelationshipDeleteAction::Restrict). The collection it belongs to is the first
argument of createRelationship(). A null key is derived from the related collection's id and a null two-way key
from the source collection's id; createRelationship() returns the relationship with both resolved.
- Read
$relationship->relatedCollection,type(RelationshipType),twoWay,key,twoWayKeyandonDelete(RelationshipDeleteAction).inverse(string $collection)is the other side's view, andapply(RelationshipUpdate $update)a changed copy. onDeletehas three cases:Cascade,RestrictandSetNull.RelationshipDeleteAction::toForeignKeyAction()converts for the adapter.- A relationship attribute of a
Collectioncarries itsRelationshipandRelationshipSidein$attribute->relationshipand$attribute->side.
// 7.x
$collection = $database->getCollection('movies');
if ($collection->isEmpty()) {
// missing
}
foreach ($collection->getAttribute('attributes', []) as $attribute) {
$type = $attribute->getAttribute('type');
}
// 8.0
$collection = $database->findCollection('movies');
if ($collection === null) {
// missing
}
foreach ($collection->attributes() as $attribute) {
$type = $attribute->type;
}Collection::create(string $id, string $name = '', array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true, array $metadata = [])builds one;Collection::fromArray()andCollection::fromDocument()read a stored definition.attributes()andindexes()return the definition asAttributeandIndexlists, built on the first call.name(),documentSecurity()anddeclaredPermissions(): ?arrayread the other fields.Collection::NAME,ATTRIBUTES,INDEXESandDOCUMENT_SECURITYname the stored keys.documentSecurityis always stored:Collection::create(documentSecurity: false)reads backfalse.$metadatatakes only keys the metadata collection stores.create()refusesattributes,indexesanddocumentSecurityin it (pass them as arguments), andcreateCollection()throwsException\Structurefor a collection carrying any other key, before anything is created.
new AttributeUpdate(?ColumnType $type = null, ?int $size = null, ?bool $required = null, mixed $default = Unchanged::Value, ?bool $signed = null, ?bool $array = null, Format|Unchanged|null $format = Unchanged::Value, ?array $filters = null, ?string $key = null) is sparse: null keeps a field. default and format use
Unchanged::Value to keep the stored value, because null is a value for them:
default: nullremoves the default, andformat: nullremoves the format.- A default on an attribute stored as required throws, and so does a default sent together with
required: true.required: truealone removes the stored default. required: falserelaxes the column'sNOT NULL. 7.x'supdateAttributeRequired(false)changed only the metadata and left the columnNOT NULL.keyrenames the attribute, likerenameAttribute().- A
Mirrorreplicates attribute updates to its destination.
new CollectionUpdate(?array $permissions = null, ?bool $documentSecurity = null) and
new RelationshipUpdate(?string $key = null, ?string $twoWayKey = null, ?bool $twoWay = null, ?RelationshipDeleteAction $onDelete = null) follow the same rule.
Collection metadata written by 7.x reads without migration: every 7.4 VAR_* type hydrates. Two kinds of stored
value make a collection unreadable, because hydrating its definition throws, so every read and write of the
collection fails until the row is repaired:
- an attribute type outside
Attribute::TYPES,uuid7included:Exception\Structure; - a relationship
onDeleteofsetDefaultornoAction:Exception\Relationship.
Repair such a row before you upgrade, while 7.x still reads it: set a supported onDelete with
updateRelationship(), and delete or recreate the attribute with a supported type. After the upgrade, edit the
collection's row in the metadata table (_metadata): in its attributes, set the relationship's options.onDelete
on both sides to cascade, restrict or setNull, or remove the attribute of the unknown type. Then drop the cached
definition with $database->purgeCachedDocument(Database::METADATA, $collection).
An index of an unknown stored type is read as a key index, so it does not block reads. Creating such an index is refused.
Database::VAR_BIGINTis replaced byColumnType::BigInteger, whose value is'biginteger'. The type stored in collection metadata is still'bigint', exactly as in 7.x: existing rows need no migration and new bigint attributes are written as'bigint'too. Do not compare or write stored type strings againstColumnType::BigInteger->value. Use the value object ($attribute->type === ColumnType::BigInteger), read a raw string withAttribute::typeFromStored($type)(it accepts'bigint'and'biginteger'), and write one withAttribute::storedType($type)(it returns'bigint'forColumnType::BigIntegerand the enum value for every other type). Error messages that name a type use the stored spelling too: a bigint default mismatch readsDefault value … does not match given type bigint, as in 7.x.toDocument()and the attribute event payloads report bigint attributes as'bigint'.- An empty
formatmeans no format: an attribute read from metadata written by 7.x, which stored'', has anullformat.
createCollection()checks every attribute likecreateAttribute(): object, spatial and vector attributes need the adapter to support them. An unknown type cannot reach it:Attribute::fromArray()andfromDocument()refuse it withException\Structure. In 7.x the SQL adapters failed withUnknown type: <type>and MongoDB created the collection.createCollection(),createAttribute()andcreateAttributes()return what they stored: the filters added fordatetime,object, spatial and vector attributes, and the indexlengthsandordersadjusted for the adapter. The value objects you pass are never changed, so shared definitions, such as the ones in a config array, are safe to pass directly. In 7.xcreateCollection()wrote these changes into the documents it was given.updateAttribute()now updatesidattributes (7.x threwUnknown attribute type: id), and it refuses relationship attributes withCannot update relationship as an attribute: useupdateRelationship().- The default of a point, linestring or polygon attribute is validated like a value of that attribute: a point needs two numeric coordinates in range, a linestring at least two points, a polygon closed rings of at least four points. 7.x accepted any array.
createAttribute()andcreateAttributes()refuse a varchar of size 0 or above the maximum varchar length, ascreateCollection()does and as 7.x did.- Index length accounting. Big integer columns (big integer, id, and 64-bit integer) count 8 bytes toward the
maximum index length, and a
text(),mediumText()orlongText()attribute without a size counts as the engine's maximum for its type. An index that relied on the old undercount has to give the text column a prefix length. - Existing indexes. On adapters with
Capability::SchemaIntrospection,createIndex()compares an index that exists in the schema but not in the metadata with the request (columns, prefix lengths, key, unique, fulltext or spatial): a match is adopted and a mismatch is dropped and recreated, as in 7.3.12. analyzeCollection()refreshes planner statistics on PostgreSQL and SQLite (the collection's table and its permissions table) and returnstrue; it returnedfalsethere. Call it after bulk loads (migrations, imports): a freshly loaded collection otherwise plans joins with empty-table statistics, and SQLite never gathers statistics on its own.- On PostgreSQL
getSizeOfCollection()reports the table's relation size, which a delete does not reduce until the table is vacuumed;analyzeCollection()refreshes statistics only.
getSchemaAttributes() returns list<Schema\Column> (name, type as the engine's canonical native type,
length, nullable) and getSchemaIndexes() returns list<Schema\Index> (name, type as IndexType,
columns, lengths), read back from the engine. Both are meaningful only where the adapter declares
Capability::SchemaIntrospection: MariaDB, MySQL, SQLite and PostgreSQL. MongoDB, Memory and Redis return [].
PostgreSQL now introspects its schema, so it reconciles orphan columns and indexes like the other SQL adapters:
createAttribute() and createIndex() adopt or replace a column or index the schema has but the metadata lacks.
PostgreSQL reports a fulltext index as a key index and no prefix lengths. An index whose name PostgreSQL shortened to
its md5 form, or another tenant's index of a shared table, is not matched and is created as before.
Database::update(string $database, string $new): bool renames a database and fires Event\Database\Updated. It
retires the cached definitions and documents of the moved collections under both names. Every adapter refuses it
under shared tables, also when called on the adapter directly.
| Adapter | How |
|---|---|
| MariaDB, MySQL | Moves every table into the new schema in one atomic RENAME TABLE, then drops the old schema when it is empty (a table created there during the rename keeps it). Grants on the old schema do not move |
| PostgreSQL | ALTER SCHEMA ... RENAME TO |
| SQLite | Nothing to move: SQLite stores no database name, so update() succeeds and exists() is always false |
| Memory, Redis | Re-keys the stored data, and rolls back on failure |
| MongoDB | Moves each collection, and rolls back on failure. A sharded cluster throws Exception. The client stays bound to the old database: build a new client for the new name |
Database::on(), Database::before(), Mirror::on() and Adapter::before() are removed, and so are the
Database::EVENT_* constants. Everything is registered with Database::addHook(), which dispatches on the hook's
type and throws for a hook it does not recognise:
| Hook interface | Receives | Replaces |
|---|---|---|
Utopia\Database\Hook\Lifecycle |
a typed event object per event, for side effects | on() |
Utopia\Database\Hook\Decorator |
each document a read or write returns, to modify it | nothing in 7.x |
Utopia\Database\Hook\Transform |
each SQL statement before it runs | before() |
Utopia\Database\Hook\Write |
document writes, to write rows of their own, such as Hook\Permissions |
built into 7.x |
Utopia\Database\Hook\Relationships |
relationship resolution and mutation | built into 7.x |
Utopia\Database\Cache\Invalidator |
the writes that invalidate the find() query cache |
nothing in 7.x |
removeHook($hook) unregisters a hook instance, or every hook of a class given its class name.
A listener is now an object implementing Utopia\Database\Hook\Lifecycle and registered with
Database::addHook(). Its handle(Event\Domain $event): void receives one typed event object per event: every
Event case except All has a final readonly class under Utopia\Database\Event\{Database,Collection,Attribute, Index,Document,Permission}, and $event->event is the Event case. Match on the class to read its typed payload. To
keep 7.x's by-name behaviour, also implement Utopia\Database\Hook\Named (getName(): string):
- Registering a named hook replaces the lifecycle hook already registered under that name, in its position. Re-registering on every request or job no longer stacks listeners. Hooks without a name are appended every time they are added.
- A name is unique per
Database, across all events. 7.x kept one listener per event and name, soon(EVENT_DOCUMENT_CREATE, 'usage', ...)andon(EVENT_DOCUMENT_DELETE, 'usage', ...)were two listeners. In 8.0 severalNamedhooks with the same name keep only the one registered last: handle all of a name's events in one hook, or give each hook its own name. silent()can silence a named hook on its own (see below).- A hook that also implements
Hook\Selective(handles(Event $event): bool) receives only the events it accepts. An event object is built only when a registered hook handles its event.
// 7.x
$database->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function (mixed $document) {
// ...
});
// 8.0
final class Usage implements Lifecycle, Named, Selective
{
public function getName(): string
{
return 'calculate-usage';
}
public function handles(Event $event): bool
{
return $event === Event::DocumentCreate;
}
public function handle(Domain $event): void
{
if ($event instanceof Event\Document\Created) {
// $event->collection, $event->document
}
}
}
$database->addHook(new Usage());On a Mirror, lifecycle hooks are registered on the source database. Mirror::silent() silences both the source
and the mirror.
Each event's payload is a typed property; 7.x passed arrays, strings, false and Document(['modified' => N]).
| Event | Class | Properties |
|---|---|---|
database_create, database_update, database_delete, database_list |
Event\Database\Created, Updated, Deleted, Listed |
database; new (update); databases (list) |
collection_create, collection_read, collection_update, collection_delete |
Event\Collection\Created, Read, Updated, Deleted |
collection, definition (Collection) |
collection_list |
Event\Collection\Listed |
collections |
attribute_create, attribute_update, attribute_delete |
Event\Attribute\Created, Updated, Deleted |
collection, attribute (Attribute) |
attribute_rename |
Event\Attribute\Renamed |
collection, old, attribute |
attributes_create |
Event\Attribute\BatchCreated |
collection, attributes |
index_create, index_delete |
Event\Index\Created, Deleted |
collection, index (Index) |
index_rename |
Event\Index\Renamed |
collection, old, index |
indexes_create |
Event\Index\BatchCreated |
collection, indexes |
document_create, document_read, document_update, document_delete |
Event\Document\Created, Read, Updated, Deleted |
collection, document |
document_upsert |
Event\Document\Upserted |
collection, document, created |
document_increase, document_decrease |
Event\Document\Increased, Decreased |
collection, document, attribute |
documents_create, documents_update, documents_delete |
Event\Document\BatchCreated, BatchUpdated, BatchDeleted |
collection, count |
documents_upsert |
Event\Document\BatchUpserted |
collection, count, created, updated |
document_find |
Event\Document\Found |
collection, documents |
document_aggregate |
Event\Document\Aggregated |
collection, rows |
document_count, document_sum |
Event\Document\Counted, Summed |
collection, count; attribute, sum |
document_purge |
Event\Document\Purged |
collection, id |
permissions_create, permissions_read, permissions_delete |
None: they only label the statements a permissions write runs, for transforms and timeouts, and reach no hook |
Event::domain() names the class of a case. New events: upsertDocument() fires document_upsert (not
documents_upsert), renameAttribute() fires attribute_rename (it fired attribute_update), createIndexes()
fires indexes_create after each index_create, aggregate() fires document_aggregate (not document_find),
and update() fires database_update. findOne() and findCollection() fire nothing on a miss; 7.x's findOne()
fired document_find with false.
silent(callable $callback, ?array $hooks = null):
nullsilences every lifecycle hook, and every decorator, for the duration of the callback. This is unchanged.- A list of names silences only the lifecycle hooks that implement
Namedwith one of those names. Unnamed hooks and hooks with other names keep firing, and so do decorators. The list holds hook names, not event names:silent($fn, ['document_create'])silences a hook nameddocument_create, not the event. - Difference from 7.x when nesting: 7.x replaced the silenced set with the inner call's list, so
silent(fn () => $db->silent($fn, ['a']))re-enabled every other listener inside. In 8.0, an innersilent()never narrows the silence around it. - Silences apply to the calling coroutine and the coroutines it starts, not to other coroutines sharing the handle (see Coroutines).
Whether a lifecycle hook's exception reaches the caller depends on the event, as in 7.x:
| Events | A hook throws an \Exception |
|---|---|
index_create, document_read, document_create, documents_create, document_update, documents_update, documents_upsert, document_increase, document_decrease, document_delete, documents_delete, document_find, document_count, document_sum, and document_purge fired by a document write or purgeCachedDocument() |
The exception reaches the caller; the remaining hooks do not run. |
All other events: database_*, collection_*, attribute_create, attributes_create, attribute_update, attribute_delete, index_rename, index_delete, and document_purge fired by an attribute schema change |
The exception is swallowed; the remaining hooks still run. |
An \Error (for example a TypeError) always reaches the caller. Differences from 7.x:
- 7.x also swallowed an
\Errorat the isolated events. - At an isolated event, 7.x skipped the remaining listeners after the first failure. 8.0 runs them.
document_purgefrom a document write now fires after the outermost transaction has committed, including for a write insidewithTransaction(), and not at all when that transaction rolls back. In 7.xupdateDocument()anddeleteDocument()fired it inside the transaction, so a failing purge listener made the transaction retry and then roll back. Now the write stays committed and the call throws; if several documents were written, everydocument_purgeis still delivered before the first listener exception reaches the caller.
A Decorator's exception always reaches the caller.
It fires once per written document from updateDocument() (for both the old and the new $id when the id changes),
updateDocuments(), upsertDocuments(), increaseDocumentAttribute(), decreaseDocumentAttribute(),
deleteDocument() and deleteDocuments(), and from purgeCachedDocument(). The event is
Event\Document\Purged with the document's collection and id. As in 7.x, createDocument() and createDocuments() do
not fire it. Attribute schema changes fire it for the collection's metadata document ($collection = _metadata).
A write fires it after the outermost transaction commits: inside withTransaction() the events of every write wait
for the outer commit, a rollback drops them, and a retried attempt announces once. Each event runs under the tenant
and the silent() scope in force when its document was written. If the cache invalidation after the commit fails,
the write throws with its data committed, after document_purge has fired. purgeCachedDocument() fires it at once. The
cache entry itself is purged inside the write's transaction, so a cache failure rolls the write back and no event
fires.
On an adapter without savepoints (MongoDB), a nested withTransaction() that fails is not rolled back on its own:
when the caller catches the failure, the nested writes commit with the caller and their events fire after that
commit.
8.0.1 fired the event just before the commit instead, by mistake; 8.0.2 fires it after the commit again.
As in 7.4.0, deleteDocument() fires document_update (Event::DocumentUpdate) for each document on the other side
of a two-way relationship that the delete changed, after its own document_delete. A lifecycle hook that handles
document_update receives them with no change. The payload is the related Document:
- A document the delete wrote (set-null clearing its key) arrives as that write returned it, with the cleared key set
to
null. A document it did not write (the parent of a deleted one-to-many child, a many-to-many peer, a peer underRestrict) arrives as read off the deleted document, without the back-reference key. Read it back to use more than$idand$collection. - One-way peers, documents a cascade removed anywhere down its chain, and the deleted document itself are not
reported.
deleteDocuments(), a cascade's own deletes and a delete insidesilent()report nothing. - Telling a cascade's survivors from what it removed takes a read per related collection. A delete makes those reads
only while an active lifecycle hook handles
document_update: any hook that does not implementHook\Selective, or one whosehandles(Event::DocumentUpdate)istrue(forEvent\DispatcherHook, a listener forEvent\Document\Updatedor a PSR-14 dispatcher). ImplementHook\Selectiveon a hook that ignoresdocument_updateto spare its deletes those reads. - They are read and written with permissions skipped and are not checked against the caller's read permission:
treat them as privileged, like the documents
deleteDocuments()andupsertDocuments()pass to$onNext. - They fire when the delete's own transaction returns, like
document_delete, not when an outer transaction commits. A delete whose commit fails and is retried reports only what the committed attempt changed. - Difference from 7.4.0: when a hook throws,
document_deleteand every relateddocument_updatestill fire, and the first exception reaches the caller afterwards. 7.4.0 stopped at the first failure.
createAttributes() fires attribute_create (Event\Attribute\Created) once per attribute, then
attributes_create (Event\Attribute\BatchCreated) once, with the list. In 7.x, createAttributes() fired
attribute_create once, with the array of attribute documents as payload, and nothing fired attributes_create.
Event\DispatcherHook forwards the typed events to listeners registered per class with
on(string $eventClass, callable $listener) and to an optional PSR-14 dispatcher. It handles an event only while a
listener or the dispatcher can receive it.
$dispatcher = new DispatcherHook();
$dispatcher->on(Event\Document\Created::class, function (Event\Document\Created $event): void {
// $event->collection, $event->document
});
$database->addHook($dispatcher);A bulk write never delivers a single-document event. Event\Document\Updated has no $previous property. Every
listener and the PSR-14 dispatcher run; the first \Exception among them is then rethrown, and whether it reaches the
caller follows the table under Hook failures. An \Error always reaches the caller at once.
A before($event, $name, $callback) transformation becomes a Utopia\Database\Hook\Transform. Its
transform(Event $event, string $query): string receives every statement with the event that runs it, so filter by
event inside it. It is registered under its class name, and removeTransform(MyTransform::class) removes it.
// 7.x
$database->before(Database::EVENT_DOCUMENT_FIND, 'label', fn (string $sql) => '/* listing */ '.$sql);
// 8.0
final class Label implements Transform
{
public function transform(Event $event, string $query): string
{
return $event === Event::DocumentFind ? '/* listing */ '.$query : $query;
}
}
$database->addHook(new Label());
$database->removeTransform(Label::class);A Hook\Write stands alone; it does not extend Utopia\Query\Hook\Write. Hook\Interceptor implements every
method as a no-op, so a hook overrides only what it needs:
decorateRow(array $row, Hook\RowMetadata $metadata): arrayadjusts each row before it is written.$metadata->tenantis the document's tenant, else the adapter's.afterDocumentCreate(),afterDocumentUpdate(),afterDocumentBatchUpdate(),afterDocumentUpsert()andafterDocumentDelete()take aHook\WriteContextas their last argument.afterDocumentUpdate()receives the id the document is stored under.Hook\WriteContextis an interface the SQL adapters implement:builder(), whosefrom($table)reads or deletes the table's rows kept to the adapter's tenant (the builder then reads that table only) and whoseinto()andfromTable(), before anyfrom(), take the namerawTable()returns and keep it to no tenant;rawTable(string $table);run(Statement $statement, Event $event): bool;fetch(Statement $statement, Event $event): array;decorateRow(array $row, Document $document);skipPermissions(Document $document): bool(the update keeps that document's permissions); andignoreDuplicates().
- The protected
trigger(string $event, mixed $args = null)is removed. Event dispatch (listens(),dispatch()) is internal: register aHook\Lifecycleto act on events. - The protected
createDocumentInstance()isnewDocument(). casting()andapplySelectFiltersToDocuments()are internal, and so are the public helpers ofHook\RelationshipsthatDatabasecalls.increaseDocumentAttribute()anddecreaseDocumentAttribute()accept numeric strings as well (string|int|float), for unsigned 64-bit values, so an override has to widen its parameter types.Databaseis now composed of the traits inUtopia\Database\Trait. Its public methods are unchanged by that.getDocument()andfind()overrides are not called for the locked read ofupdateDocument(),increaseDocumentAttribute(),decreaseDocumentAttribute()anddeleteDocument()or the page reads ofupdateDocuments()anddeleteDocuments().getCollection()andfindCollection()overrides are not called bygetDocument(Database::METADATA, ...)while noHook\Decoratoris registered or events are silenced (silent()without hook names).- The protected
$listenersand$silentListenersproperties are gone. Registered lifecycle hooks are in the protected$lifecycleHooks; to silence or test for silence, usesilent()and the protectedareEventsSilenced(). - The protected
decodeAttribute()applies the filter it is given.decode()readssetFiltering()andskipFilters()once per document and calls it only for the filters they leave enabled, so an override that relied on the method returning the value unchanged while filters are disabled no longer has to check.
- A retried delete no longer skips its cascade. In 7.x, a cascade that threw (for example on a
Restrictedrelated document or a permission failure) left the relationship on the database's internal delete stack, and a later delete of the same document on the sameDatabaseinstance then deleted it without cascading. The cascade now always runs. - Cascade, set-null and link permission failures throw
Utopia\Database\Exception\Authorizationand roll back the write, as in 7.x. Related documents are now read and linked in chunks ofmin(Database::RELATION_QUERY_CHUNK_SIZE, getMaxQueryValues()); a cascade deletes them one at a time throughdeleteDocument(), so a related document the caller may not read is deleted with the rest or, underRestrict, blocks the delete. - Linking an existing related document, at any nesting depth of a create or an update, needs update permission on
that document, for every relationship type; without it the write throws
Utopia\Database\Exception\Authorizationand nothing is written. In 7.x a many-to-many link needed only read permission on the related document, because the link is a row in the junction collection rather than a column on the related document, and so did a link by id from the side that holds the key: a one-way one-to-one, a many-to-one, and a one-to-many child pointing at its parent. These now need update permission too, whether the value is an id or a document (since 8.0.1; 8.0.0 still linked them with read permission). A related document the same write creates needs no update permission. Relinking a document that is already linked needs only read permission, and removing a link needs no update permission on the unlinked document. - A stored related document the caller cannot read is refused with
Utopia\Database\Exception\Authorization, with the read check's reason when the value is its id, and with the update check's reason (or the read check's when the caller may update it) when the value is a document. 7.x left a one-to-one or many-to-one link to it, dropped it from a one-to-many or many-to-many value, and tried to create it when it was given as a document, failing withUtopia\Database\Exception\Duplicate. Database::authorizeCreate(string $collection, Document $document)andDatabase::authorizeUpdate(string $collection, Document $old, Document $document)(since 8.0.1) run these checks, and the create or update check on the document itself, without writing anything. An application that stages writes to run later, such as the operations of a transaction another request commits, calls them at staging instead of repeating the checks.authorizeUpdate()checks the update against$old, the stored document as the caller reads it, and always needs update permission, whereupdateDocument()lets an update that changes nothing through with read permission. Since 8.0.3 both take an optional$resolve(string $collection, string $id): ?Document, the related documents as the writes staged earlier leave them: the document with its permissions, unfiltered by the caller's read, an emptyDocumentfor one they delete, ornullfor one they leave as stored. Pass it when the staged write runs after other staged writes, so a related document staged for deletion is judged as new (create on its collection) and one staged for an update or create by its staged permissions and links, as the later write finds them. Pass$oldas the staged writes leave it too.deleteDocuments()appliesCascadeandRestrictto every related document, also when its queries include a select (7.x skipped the cascade and ignoredRestrictthen) and also to related documents the caller cannot read. Each document a cascade reaches is deleted throughdeleteDocument(), so one the caller may not delete throwsUtopia\Database\Exception\Authorizationand rolls the batch back.
-
new Document([...])andDocument::setAttribute('$permissions', ...)throwUtopia\Database\Exception\Structure(Every permission must be of type string) when a permission is not a string. In 7.x the entry was kept, andcreateDocument()/updateDocument()rejected it with the same message as aUtopia\Database\Exception, but only while validation was enabled.setAttribute('$permissions', $value)also throws ($permissions must be of type array) when$valueis neither an array nornull, matching the constructor. Permissions set through the constructor orsetAttribute()are de-duplicated and re-indexed, andgetPermissions()always returns a list. -
Reading stored data never throws for a non-string permission:
Document::fromRow(), which the SQL adapters use, and the newDocument::fromStorage(), which the MongoDB, Memory and Redis adapters use, drop such entries at every nesting level. The same holds for ajsonattribute whose value is shaped like a document (7.x stored such values with non-string permissions), for documents rebuilt from the document and query caches, and for a mapped document type (setDocumentType()), which keeps its class. OtherwisefromStorage()builds a document like the constructor: nested arrays carrying$idor$collectionbecomeDocuments, and a non-string$idor a$permissionsvalue that is not an array still throws. Build caller input with the constructor, which rejects non-string permissions. -
updateDocument()validates only the values it changes: an attribute whose value equals the stored one is not validated again, so a value an older release accepted (for example a list in anobjectattribute) no longer blocks updates of other attributes. Writing such a value still fails. Collection definitions are always validated in full. -
Document::setAttribute()takes aSetTypecase, andDocument::getPermissionsByType()takes aPermissionTypecase or, for a permission type of your own, its string. -
createDocuments()underignoreDuplicates()(7.xskipDuplicates()) returns and counts only the documents it inserted, and hands only those toonNext. A document skipped because its id is already stored is neither counted nor emitted (7.x counted and emitted it). Of a batch that repeats an id, only the first copy is written. Permissions are written only for inserted documents: in 7.x the skipped copy's permissions were added to the stored document on MariaDB, MySQL and SQLite, sofind(),count()andsum()could return it to roles its own permissions do not grant. -
Fractional numbers on integer attributes. Since 8.1.2 these are rounded instead (see Integer rounding). In 8.0,
increaseDocumentAttribute()anddecreaseDocumentAttribute()throwUtopia\Database\Exception\Typebefore anything is written, on every adapter and also on schemaless collections that declare the attribute as an integer:- for a fractional change value (
Change value must be an integer.). 7.x passed it to the engine, which rounded it on MariaDB and MySQL, failed on PostgreSQL, and stored a float in the integer attribute on SQLite, MongoDB, Memory and Redis. Pass an integer change value, or use a float attribute for fractional counters. - for a fractional
maxormin(Max must be an integer.,Min must be an integer.). Integer bounds are compared with exact integer arithmetic, so 64-bit and unsigned values never pass through a float. The bound accepts the same whole numbers as an operator limit: an integer, an integer string, a string with only zero decimals such as'102.0', or a float without a fractional part such as102.0, each converted exactly. Pass a whole bound, for examplefloor($max)orceil($min), which admits the same integer values.
Change values and bounds on float and double attributes may still be fractional.
- for a fractional change value (
-
Operator limits on integer attributes. The
maxorminlimit ofOperator::increment(),decrement(),multiply(),divide()andpower()on an integer or big integer attribute has to be a whole number: an integer, an integer string, or a float without a fractional part such as9.0e18. A fractional limit such as102.4is refused before anything is written withUtopia\Database\Exception\Structure(Cannot apply <operator> operator: max/min limit must be a whole number for integer attribute '<key>', got <limit>). With validation skipped that check does not run, and Memory and Redis refuse such a limit withUtopia\Database\Exception\Operatorwhen the result leaves PHP's integer range. Limits on float and double attributes are unchanged. Numeric text such aspower('2')is accepted. -
8.0.1 passed fractional change values and bounds on integer attributes to the engine, accepted fractional operator limits on integers, and threw
\InvalidArgumentExceptionfor a change value of 0 or less, by mistake; 8.0.2 refuses them again as described above. -
Operators on upserts that create a document. An upsert that creates a document applies every operator to the attribute's default, as it does for an existing document:
dateAddDays()anddateSubDays()shift the date,arrayFilter()filters the array, and the maximum or minimum of increment, decrement, multiply, divide and power is honoured. Code that relied on the default being stored unchanged gets the operator's result. -
Documentmethods.7.x 8.0 getRead(),getCreate(),getUpdate(),getDelete(),getWrite()getPermissionsByType(PermissionType::Read)and so on.Writecovers create, update and deletegetArrayCopy(array $allow = [], array $disallow = [])getArrayCopy()without arguments,only(array $keys)for$allowandexcept(array $keys)for$disallowfind(),findAndReplace(),findAndRemove()No replacement: read and write the attribute with getAttribute()andsetAttribute()only()andexcept()filter top-level keys only, as$allowand$disallowdid, and convert nested documents to arrays likegetArrayCopy();only([])returns[].Document::fromRow()andfromStorage()are internal.
// 7.x
$readers = $document->getRead();
$response = $document->getArrayCopy(disallow: ['$permissions']);
// 8.0
$readers = $document->getPermissionsByType(PermissionType::Read);
$response = $document->except(['$permissions']);| 7.x | 8.0 |
|---|---|
createDocuments($collection, $documents, $batchSize = INSERT_BATCH_SIZE, $onNext, $onError) |
createDocuments(string $collection, array $documents, int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null): int |
updateDocuments($collection, $updates, $queries, $batchSize, $onNext, $onError) |
updateDocuments(string $collection, Document $updates, array $queries = [], int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null): int |
upsertDocuments($collection, $documents, $batchSize, $onNext, $onError) |
upsertDocuments(string $collection, array $documents, int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null, ?string $increase = null): int |
upsertDocumentsWithIncrease($collection, $attribute, $documents, $onNext, $onError, $batchSize) |
upsertDocuments(..., increase: $attribute) |
deleteDocuments($collection, $queries, $batchSize = DELETE_BATCH_SIZE, $onNext, $onError) |
deleteDocuments(string $collection, array $queries = [], int $batchSize = Database::BATCH_SIZE, ?callable $onNext = null): int |
iterate($collection, $queries, $forPermission), foreach($collection, $callback, $queries, $forPermission) |
cursor(string $collection, array $queries = [], int $batchSize = Database::CURSOR_BATCH_SIZE, PermissionType $forPermission = PermissionType::Read): Generator |
find() with aggregates or groupBy() |
aggregate(string $collection, array $queries): array, a list of rows |
purgeCachedDocument(string $collectionId, ?string $id): bool, purgeCachedCollection(string $collectionId): bool |
purgeCachedDocument(string $collection, string $id): void, purgeCachedCollection(string $collection): void |
// 7.x
$database->deleteDocuments('sessions', [$expired], onNext: fn (Document $deleted, Document $old) => $log($old), onError: fn (Throwable $error) => $report($error));
foreach ($database->iterate('sessions', [Query::limit(25)]) as $session) {
// ...
}
// 8.0
$database->deleteDocuments('sessions', [$expired], onNext: fn (Document $deleted, ?Document $previous) => $log($previous));
foreach ($database->cursor('sessions', batchSize: 25) as $session) {
// ...
}$onNexthas one shape everywhere:callable(Document $document, ?Document $previous): void.$previousisnullon create, the stored document an update or upsert replaced, and on delete a separate copy of the deleted document, so changing the first argument does not change it.$onErroris removed: an exception from$onNextaborts the call, like any other callback.- Batch size.
Database::BATCH_SIZE(1000) replacesINSERT_BATCH_SIZEandDELETE_BATCH_SIZE. A batch size above it throwsException\Limitinstead of being reduced to it; one below 1 still writes one document at a time. cursor()is the only generator. It reads the matches in batches of$batchSize(defaultDatabase::CURSOR_BATCH_SIZE, 100). Alimit()in the queries caps how many documents it yields; anoffset()orcursorAfter()positions the first batch only;cursorBefore()throwsUtopia\Database\Exception(Cursor before not supported in this method.) when called. Initerate()andforeach()alimit()set the page size, and without one they read pages of 25: move the limit tobatchSize, or passbatchSize: 25, to keep the page size.aggregate()returnslist<array<string, mixed>>. An unaliased aggregate comes back under<method>_<attribute>, or<method>forcount('*'); give it an alias to choose the name. Two aggregates that would come back under one name (two identical unaliased ones, or a default that equals another's alias) throwException\Query. It firesdocument_aggregate.find()refuses aggregate andgroupBy()queries withException\Query.sum()andcount()throwException\Queryfor a$maxof 0 or less; they returned 0.increaseDocumentAttribute()anddecreaseDocumentAttribute()throwException\Typefor a change value of 0 or less; they threwInvalidArgumentException.findOne()fires nodocument_findon a miss.deleteDocument()still returnsbool.
Every toggle has one form: set<Noun>(bool): static sets it, is<Participle>() or has<Noun>() reads it, and a
scoped form takes the value and a callback. Setters return static and reset/clear methods return void.
| 7.x | 8.0 |
|---|---|
enableValidation(), disableValidation() |
setValidation(bool $validation); read with isValidating(); scope with withValidation(bool $validation, callable $callback) or skipValidation(callable $callback) |
enableFilters(), disableFilters() |
setFiltering(bool $filtering); read with isFiltering(); scope with withFiltering(bool $filtering, callable $callback, ?array $filters = null) or skipFilters(callable $callback, ?array $filters = null) |
enableLocks(bool $enabled) |
setLocks(bool $locks) |
getPreserveDates(), withPreserveDates(callable $callback) |
isPreservingDates(), withPreserveDates(bool $preserve, callable $callback) |
getPreserveSequence(), withPreserveSequence(callable $callback) |
isPreservingSequence(), withPreserveSequence(bool $preserve, callable $callback) |
skipDuplicates(callable $callback) (database and adapter) |
ignoreDuplicates(callable $callback) |
getDropUnknownAttributes() |
isDroppingUnknownAttributes() |
getSharedTables() (database and adapter) |
hasSharedTables() |
getTenantPerDocument() (database and adapter) |
isTenantPerDocument() |
clearDocumentType(string $collection): static |
clearDocumentType(string $collection): void |
clearAllDocumentTypes(): static |
clearDocumentTypes(): void, which keeps the metadata collection's Collection type |
setMigrating(): self, setMaxQueryValues(): self, setAuthorization(): self |
Return static |
silent(callable $callback, ?array $listeners = null) |
silent(callable $callback, ?array $hooks = null) |
getKeywords() |
$database->profile()->limits->keywords |
getConnectionId(): string |
getConnectionId(): ?string, null on an adapter without Feature\Connection (Memory) |
| — | getHostname(): ?string. ping() returns true and reconnect() does nothing without Feature\Connection |
Adapter setDatabase(), setSharedTables(), setTenant(), setTenantPerDocument() returned bool |
Return static |
Adapter setDebug(), getDebug(), resetDebug() |
setMetadata(), getMetadata(), resetMetadata(), which also reach the query comments |
Adapter enableAlterLocks(bool $enable) |
Database::setLocks(bool $locks) |
Authorization::setDefaultStatus(bool $status) |
new Authorization(defaultStatus: $status) |
Authorization::addRole(), removeRole(), cleanRoles(), setStatus(), enable(), disable() returned void |
Return static |
new Authorization\Input(string $action, array $permissions) |
new Authorization\Input(PermissionType|string $action, array $permissions): pass a PermissionType case for the built-in actions; an action of your own, such as execute or subscribe, stays a string |
setProfiling(bool) and isProfiling() turn the query profiler on and off (see
Pools and profiling).
setTenant() and withTenant() are unchanged.
// 7.x
$database->disableValidation();
$database->withPreserveDates(fn () => $database->createDocument('logs', $log));
$database->skipDuplicates(fn () => $database->createDocuments('logs', $logs));
// 8.0
$database->setValidation(false);
$database->withPreserveDates(true, fn () => $database->createDocument('logs', $log));
$database->ignoreDuplicates(fn () => $database->createDocuments('logs', $logs));Under Swoole, several coroutines can share one Database and one Authorization. The scopes that 7.x applied to the
whole handle now apply to the calling coroutine and the coroutines it starts; sibling coroutines sharing the handle
or the Authorization do not see them:
Authorization::skip()andAuthorization::withRoles();silent(),skipRelationships(),skipRelationshipsExistCheck(),withFiltering()andskipFilters(),withValidation()andskipValidation(),withPreserveDates(),withPreserveSequence(),withTenant(),withRequestTimestamp(), andignoreDuplicates()on the database and on the adapter.
A plain setter is scoped only by a scope over the same state:
Authorization::setStatus(),enable(),disable()andreset()byAuthorization::skip();Authorization::addRole(),removeRole()andcleanRoles()byAuthorization::withRoles();setTenant()bywithTenant();setValidation()bywithValidation()andskipValidation();setFiltering()bywithFiltering()andskipFilters(), with or without filter names;Hook\Relationships::setEnabled()byHook\Relationships::withEnabled()andskipRelationships();setPreserveDates()bywithPreserveDates(), andsetPreserveSequence()bywithPreserveSequence().
Outside every scope over its state, a setter in the coroutine that opened a scope, or in a coroutine it started that
is still connected to it, changes the shared value, as in 7.x: disable() inside withRoles() turns authorization
off for every coroutine sharing the Authorization. A coroutine cut off from the scope is covered in the next
paragraph. Inside such a scope, whether the calling coroutine opened it or inherited it from the coroutine that
started it, a setter changes only what the calling coroutine and the coroutines it starts see, and only until the
scope ends. When the scope ends, the value is what it was before the scope, as in 7.x.
A coroutine sees a scope only while every coroutine between it and the scope's owner is still running, because Swoole cannot report the parent of a coroutine that has finished. In a coroutine cut off this way:
- reads see the shared values, or a scope opened outside every coroutine, but never a scope opened in a coroutine
(not the owner's tenant, status or roles), and on
Adapter\Poolit borrows a connection of its own outside the scope's transaction; - writes stay its own: while a scope over that state is open on the handle, even one another coroutine opened, a
setter changes only what that coroutine and the coroutines it starts see, until it ends, and never the shared
value. For
Authorization,skip()andwithRoles()each count as a scope over both the status and the roles; - with no such scope open, a setter changes the shared value, as in 7.x.
A connected coroutine and a cut-off one treat a setter differently on purpose. A connected coroutine knows the scopes
it runs under, so a write to a state none of them covers follows the 7.x rule: a grandchild's disable() inside
withRoles() turns authorization off for every coroutine. A cut-off coroutine cannot tell which scope it ran under,
so it keeps the write local: the same disable() there applies only to it and the coroutines it starts.
A cut-off coroutine must not change and then restore state with a pair of setters, such as disable() then
enable(), or setTenant($tenant) then setTenant($original). Each write is shared or local depending on whether a
scope over that state is open on the handle at that moment, so the restore can stay local while the change stays
shared. Use skip(), withRoles(), withTenant() or another with*() scope, which always restores the value
when it ends.
While a cut-off coroutine holding such a local write is alive, reads of that state take the slower scoped path in every coroutine sharing the handle, as they do while any scope over it is open, so a long-lived coroutine should open its own scopes around the work that needs them rather than call setters.
Work that can outlive the coroutine that started it loses that coroutine's scopes once it returns, so it opens the
scopes it needs itself, inside the child. Hook\Relationships::withEnabled() and withCheckExist() scope the hook's
own flags the same way as the database scopes.
Database::snapshot(), Database::withSnapshot() and Hook\Relationships::withSnapshot() are @internal:
Mirror and the relationship hook use them to carry the caller's state (the authorization status and roles, the
relationship, silence and filter state, the tenant, the toggles and the request timestamp, but not a transaction) into
the coroutines they start. They are not part of the public API.
Relationship population reads its chunks of related ids concurrently only on Adapter\Pool, inside a coroutine and
outside withTransaction(), and only as many at once as Hook\Relationships::READ_CONCURRENCY (4) and
Pool::getReadConcurrency() (the connections the pool can hand out without waiting, less one) allow; elsewhere it
reads them one after another. Related documents are merged in chunk order.
Each coroutine sharing a handle tracks its own relationship writes and cascading deletes, so a nested write or a
cascade in one coroutine never cuts another coroutine's short. On Adapter\Pool, a transaction belongs to the
coroutine that opened it and the coroutines it starts; see Pools and profiling.
-
Exception hierarchy.
Exception\Schemais the new parent ofStructure,Type,Character,Truncate,Index,Dependency,LimitandRelationship, so onecatch (Exception\Schema)handles every schema violation.OrderandOperatornow extendException\Query: acatch (Exception\Query)also catches them, so put acatchofOrderorOperatorbefore it.Exception\Transactionkeeps exactly its subtree (Contention);TimeoutandUnconfirmedkeep their parents, sowithTransaction()never runs them again. -
Exception constructor.
Exception::__construct(string $message = '', int|string $code = 0, ?\Throwable $previous = null)takes every argument as optional and keeps a string code, such as the SQLSTATEHY000, inpublic readonly ?string $state; a numeric string is also the integer code.Exception\Order::__construct(string $message, ?string $attribute = null, int|string $code = 0, ?\Throwable $previous = null)takes the attribute second. -
Unique index violations. Every adapter reports a unique index violation as
Utopia\Database\Exception\Uniquewith the 7.x messageUnique index violation. The class and its hierarchy are unchanged:UniqueextendsDuplicate, and a conflicting document$idstill throws a plainDuplicatewithDocument already exists. Match on the class, not the message: catchUniquebeforeDuplicateto tell the two apart.Exception\Uniquehas no constructor of its own and never rewrites the message it is given. The message isException\Unique::MESSAGE, also whencreateIndex()refuses a unique index over documents that already share a value (7.4.1 Redis:Cannot create unique index: existing rows already contain duplicate values). -
ignoreDuplicates()on PostgreSQL skips only a document whose id is stored, as in 7.x: a new id that collides on another unique index throwsUtopia\Database\Exception\Unique. MariaDB, MySQL and SQLite cannot name the index to ignore and, as in 7.x, skip such a row without error. -
Retries of metadata writes. Schema calls that persist a collection definition (
createAttribute(),createIndex(), their update, rename and delete siblings,createRelationship()) no longer retry a failure that fails the same way every time:Authorization,Character,Conflict,Dependency,Duplicate(andUniqueandMismatch),Index,Limit,NotFound,Operator,Order,Query,Refused,Relationship,Restricted,Structure,Timeout,Truncate,TypeandUnconfirmedare thrown on the first attempt. A failure the metadata write's transaction retries itself (see Transaction retries) is not run again by the schema call, so its retries do not multiply. Other failures, such as an unavailable cache, are still attempted up to three times. -
Transaction retries.
withTransaction()runs the callback again only after a failure that can succeed on another attempt: anException\Transaction(a lock conflict, which isException\Contention, or a failed begin, commit or rollback), a lost connection (also as the cause of another failure), an engine lock conflict the adapter did not map, or, on MongoDB, an error labelledTransientTransactionError, a network error before the commit or a command that was never sent. Every other failure runs the callback once and is rethrown at once, in a nested call too: every typed library failure (Structure,NotFound,Query,Type,Index,Dependency,Truncate,Duplicate,Timeout, ...) and any other exception. 7.x retried everything butDuplicate,Restricted,Authorization,Relationship,Conflict,LimitandTimeouttwice, sleeping 50 ms and 100 ms, before rethrowing the same failure. A callback that throws its own exception to have the transaction run again must throwException\Transaction(orException\Contention) instead. Only the outermostwithTransaction()retries: a call nested in another rolls back to its savepoint and rethrows, and the outermost call runs the whole unit again, releasing its locks between attempts. A lock conflict that keeps failing therefore runs a nested callback 3 times, not 9 (7.x retried in the savepoint too). A call nested in a transaction begun withstartTransaction()still retries in its savepoint. -
Commits with an unknown result. On MongoDB, when the result of a commit is unknown,
withTransaction()sends only the commit again, up to 3 more times, with amajoritywrite concern and a 10 swtimeout, and does not run the callback again. Each retry can wait up to that 10 s while it holds the connection. The result is unknown after a network error, a primary change or shutdown,MaxTimeMSExpired(50) orExceededTimeLimit(262) during the commit, or an error labelledUnknownTransactionCommitResult, so codes 50 and 262 at commit no longer surface asException\Timeout. After a socket timeout or send failure the client drops the connection with its sessions, so the commit cannot be sent again andUnconfirmedis thrown after a 50 ms pause, when the first retry finds the session gone: only a commit that failed with a primary change, shutdown, time limit or label is sent again, and a retry the client could not send is tried once more. If the commit still cannot be confirmed,withTransaction()throwsUtopia\Database\Exception\Unconfirmed, whosegetPrevious()is the first commit error, and does not run the callback again: treat the work as possibly committed and re-read before acting on it. The queueddocument_purgeevents of its last attempt still fire, because its writes may be stored. 7.x ran the whole callback again, which could store its writes twice. A schema call whose definition write ends inUnconfirmedrethrows it unchanged and keeps the table, column or index, as after a failure once the definition is stored (see below). On a sharded cluster (mongos) the adapter runs without transactions, as on a standalone server. -
Commits the server reports aborted. On MongoDB, a commit that the server reports aborted (
NoSuchTransaction(251) orWriteConflict(112)) stored nothing, sowithTransaction()runs the callback again, within its usual 2 retries (20 for a write conflict, after a short randomised wait, so concurrent writes to one document all land), whether it was the first commit or a retry; when the retries run out it throwsUtopia\Database\Exceptionwith anException\Transactioncause. 7.x reported a first commit the server had aborted as a success, so the callback's writes were lost while the call returned normally. MongoDB aborts the whole transaction on a failed write in it, so a callback that catches a failed write (aDuplicate, for example) and carries on now runs again and then fails, where 7.x returned without storing any of its writes. On the SQL adapters a connection lost duringCOMMITstill runs the callback again: the work is at-least-once on a SQL connection loss duringCOMMIT. -
Failed rollbacks of metadata writes. When a definition cannot be persisted and the schema change's rollback fails too, the thrown
Utopia\Database\Exceptionnames the persistence error first and the rollback's after| Cleanup error:, and itsgetPrevious()is always the persistence error. In 7.x the message labelled the two the other way round, and some calls reported only the rollback's error. -
Failures after a schema change committed. When a definition is stored and only the cache invalidation or events after it fail, or its commit ends in
Exception\Unconfirmed, the schema call rethrows that failure unchanged, does not retry it and does not undo the physical change:createCollection(),createAttribute(),createAttributes()andcreateIndex()keep the table, column or index,updateAttribute(),renameAttribute()andrenameIndex()keep the change, anddeleteAttribute()anddeleteIndex()leave the column or index dropped.createRelationship()also completes the relationship's indexes before rethrowing it. -
Deletes whose definition may be stored.
deleteCollection()anddeleteRelationship()drop the table or columns before they remove the definition. When that removal fails, also after its commit or with anException\Unconfirmedcommit, they recreate the table or columns empty and throw aUtopia\Database\ExceptionwhosegetPrevious()is the failure. If the removal was stored, this leaves an empty table or column without a definition:createRelationship()with the same key reuses the columns, andcreateCollection()with the same id throwsException\Duplicateunless tables are shared, where it reuses the table (on MongoDB creating the collection's indexes again). The library does not rundeleteCollection()again, and the wrapper no longer has theUnconfirmedclass, so a caller that retries on it runs the delete again: harmless when the removal was not stored, andException\NotFoundwhen it was. -
deleteCollection()deletes the relationships first. It deletes each of the collection's relationships, with their columns, junction collections and the related collections' two-way attributes, before it drops the collection's own table. When the table drop or the definition removal then fails, the relationships stay deleted while the collection remains. RunningdeleteCollection()again converges: it finds no relationships left and drops the rest. AMirrorforwards the delete to its destination only after the source succeeds, so until the retry the source has lost the relationships the destination still has. -
Adapter refusals. When an adapter returns
falsefrom a schema change instead of raising an error, the call throwsUtopia\Database\Exception\Refused, a subclass ofUtopia\Database\Exception, whosegetPrevious()isnull. This coverscreate(),update(),delete(),createCollection(),deleteCollection(),createAttribute(),createAttributes(),updateAttribute(),deleteAttribute(),renameAttribute(),createIndex(),createIndexes(),deleteIndex(),renameIndex(),createRelationship(),updateRelationship()anddeleteRelationship(), and nothing is written to the metadata or the caches after a refusal. Most keep their 7.x message (Failed to create attribute), and the database and collection calls, which ignored afalsein 7.x (a refusedcreateCollection()stored its definition anyway), sayFailed to create database,Failed to delete database,Failed to create collectionandFailed to delete collection. A refused rename or relationship update has one message naming its keys:Failed to rename attribute 'a' to 'b',Failed to rename index 'a' to 'b',Failed to rename database 'a' to 'b'andFailed to update relationship 'k'.update()anddelete()therefore returntruewhenever they return. An adapter still answerstruefor a state that already holds, such as a database that exists or is already gone. 7.x wrapped those three refusals in the message it uses for an adapter error (Failed to rename attribute 'a' to 'b': Failed to rename attribute), so a refusal looked like an engine failure. An error the adapter raises reaches the caller as itself, except fromrenameAttribute(),renameIndex()andupdateRelationship(): there anException\DuplicateorException\NotFound(such as MariaDB/MySQL 1054 for a missing column) still reaches the caller as itself, and any other error is wrapped in a plainUtopia\Database\Exception(Failed to rename attribute 'a' to 'b': <error>) whosegetPrevious()is the error.renameIndex()andupdateRelationship()first check whether an earlier attempt already made the change, and complete it when it did. 7.x wrappedDuplicateandNotFoundtoo. CatchException\Refusedto tell a refusal apart instead of matching the message or the nesting of causes. A refusedrenameIndex()no longer renames back and forth to complete an earlier rename: an adapter returnsfalseonly when the index is under neither name. WhencreateAttributes()falls back to creating the columns one at a time, a column the adapter does not create is refused too; 7.x stored it in the metadata as created, and drops the columns it had already created. On MongoDB,createCollection()drops a collection it created but could not give its indexes, so a later create no longer adopts it without them; see MongoDB: collections. -
deleteRelationship()whose drop fails. The relationship's indexes, or its junction collection's definition, are removed before the adapter drops its columns or junction tables, because SQLite cannot drop an indexed column and the engines disagree on what a column drop does to its indexes. When the adapter refuses the drop or raises an error, the indexes and the junction definition are now recreated and the failure is rethrown unchanged; 7.x left them dropped while the relationship stayed. -
Engine errors mapped to library exceptions.
Engine condition Exception MariaDB/MySQL deadlock (1213) or lock wait timeout (1205); PostgreSQL deadlock (40P01), serialization failure (40001) or lock not available (55P03); SQLite database is locked(5)Exception\Contention, a subclass ofException\Transaction, whichwithTransaction()retries twice before rethrowingMariaDB/MySQL statement on a missing table (1146) Exception\NotFound(Collection not found), as 1051, PostgreSQL 42P01 and SQLiteno such tableMariaDB/MySQL index on a column the table lacks (1072); SQLite no such columnException\NotFound(Attribute not found), as 1054 and PostgreSQL 42703PostgreSQL invalid UTF-8 (22021) Exception\Character(Invalid character), as MariaDB/MySQL 1366PostgreSQL 42P01 naming something other than a collection table (an undeclared or mis-quoted alias) Exception\Query(Query references an undefined table or alias); a missing collection table staysNotFoundPostgreSQL 42P10 of a distinct()read ordered by an unselected attributeException\Query, in any server language (lc_messages)PostgreSQL undefined function or operator (42883), for example max()over a booleanException\QueryrenameAttribute()orupdateAttribute()with a new key onto a column that exists beside the old one (every SQL engine)Exception\Duplicate(Attribute already exists); PostgreSQL threw a genericException, and the other engines adopted the columnShared tables: a column another tenant's collection stores with another type Exception\Duplicate(Attribute exists in the shared table with another type); PostgreSQL throws its subclassException\Mismatch -
Database::deleteCollection()on PostgreSQL succeeds again when the collection's table is already gone (7.x behaviour): the metadata is removed and the permissions table is dropped. On MariaDB and MySQL it drops the permissions table in that case too, so the collection can be created again. -
MongoDB
count()no longer returns0when the query fails: it throws the mapped driver error (a timeout still throwsException\Timeout). Code that treated a failed count as zero has to catch the exception. -
Features an adapter lacks. On an adapter without timeouts (SQLite, Memory, Redis),
Database::setTimeout()andclearTimeout()throwUtopia\Database\Exception(Adapter does not support timeouts). 7.x's SQLite, which inherited them from MariaDB, ignored them.getConnectionId()andgetHostname()returnnullon an adapter withoutFeature\Connection(Memory).schema()throwsSchema builder is not supported by this adapterwherefrom()throws for the query builder.getSchemaAttributes()andgetSchemaIndexes()return[]withoutCapability::SchemaIntrospection. -
Unknown columns on MariaDB and MySQL. A statement that names a column the table lacks (1054) now throws
Utopia\Database\Exception\NotFound(Attribute not found) instead of a rawPDOException, as PostgreSQL already did. This includes a table that has drifted from its metadata:find(),count()andsum()that filter, order or group by the missing column, writes that name it,updateAttribute()andrenameAttribute()on it, and raw queries. -
Database::sum()now validates its attribute (the second argument) the way aQuery::sum()aggregate is validated, and throwsUtopia\Database\Exception\Querywhen it does not name a numeric, non-array attribute: of the main collection or, under a join alias, of the collection that join reads. In 7.x an unknown attribute reached the engine and failed there, and a string or array attribute returned 0 (or failed on PostgreSQL). Sum only numeric attributes. With joins, a bare name resolves as in an aggregation query (see Aggregations), and a joined attribute can always be qualified with its alias (sum('orders', 'item.price', [$join])).
- Cache key names changed: do not share a cache between 7.x and 8.0 processes. A document is cached in one hash
per document, as in 7.x, but the key now includes the database name
(
{cacheName}-cache-{hostname}:{database}:{namespace}:{tenant}:collection:{collection}:{id}; 7.x used{cacheName}-cache-{hostname}:{namespace}:{tenant}:collection:{collection}:{id}), with one field per selection whose value records the collection epoch it was filled under. 7.x and 8.0 keys are disjoint, so neither version reads or invalidates the other's entries. During a rolling upgrade on one cache this holds in both directions: a 7.x process keeps serving documents, permissions and collection definitions that an 8.0 process has changed, and an 8.0 process keeps serving what a 7.x process has changed (a revoked permission included), until the entry expires after the cache TTL (Database::TTL, 24 hours). Deploy without overlap, or run both sides without a cache during the overlap (for example withUtopia\Cache\Adapter\None; a write from an uncached side cannot invalidate the other side's entries), and flush the cache once the last 7.x process has stopped. - Invalidation. A single-document write (
createDocument(),updateDocument(),increaseDocumentAttribute(),decreaseDocumentAttribute(),deleteDocument()) andpurgeCachedDocument()purge only that document, inside the transaction and again after the outermost commit or rollback; other cached documents of the collection stay cached. Batch writes and schema changes retire the collection's cached documents at once. If the purge after a commit fails, the write throws with the data committed, and the collection's cached documents are retired instead. - A write reads under the definition it resolved. The locked read of
updateDocument(),increaseDocumentAttribute(),decreaseDocumentAttribute()anddeleteDocument(), and every page read ofupdateDocuments()anddeleteDocuments(), use the collection definition the write resolved first. A schema change committed after that, or between two pages, is not seen by that read or those pages; the write already validated and encoded under the earlier definition. A bulk write reads_metadataat most once per call instead of once per page. See Subclasses ofDatabasefor the overrides these reads skip. - Transactions. Inside
withTransaction()a read uses the cache only for documents the transaction has not written; it never fills the cache. A transaction started on the adapter directly reads uncached (see Known limitations). - Read replicas. With
ReadWritePool, reads served by a replica are not cached; only reads the pool sends to the primary (in a transaction or within the sticky window after a write) fill the document and query caches. - Collection definitions carry the document-cache epoch. A cached collection definition holds, per tenant, the
epoch its collection's documents are cached under, so a cached
getDocument()costs two cache round trips (the definition and the document) andgetCollection(),find(),count()andsum()one before their query, as in 7.x. Batch writes and schema changes purge the collection's definition when they retire its documents, so the first read afterwards also reads the definition from the database once. - Use a cache adapter with generations (
Utopia\Cache\Feature\Leasable) for the document cache too: without them a read that overlaps a write can cache the previous row until the next write or the TTL. - Query cache layout.
find()results are cached in one hash per collection scope. Each query and role context maps to one ofslotsfields (new Cache\Query($cache, slots: $count), 1024 by default), and the value records the query and the epoch it was filled under, so a hit needs both to match. An invalidation publishes a new epoch and deletes nothing: an older result stays until a fill of its slot replaces it. The number of keys and fields no longer grows with writes or with distinct queries, and a write's cost does not depend on how many results are cached. A collection scope keeps at mostslotsresults, so its memory is bounded byslotstimes its largest result, and queries sharing a slot evict each other. All of a collection's results share one key: under Redis Cluster they live on one slot, and an invalidation rejects every in-flight fill of that collection. On a cache without fields (Memory, Filesystem) a collection scope holds one result at a time. - Abandoned writes. A write that blocks a collection's cache and never finishes its invalidation (a worker killed
mid-transaction) no longer keeps that cache off until a flush. For the document cache the limit is
$database->setCacheWriterTimeout($seconds), 3600 seconds by default. The query cache uses the writer timeout and the cache name of theDatabasethat calls it, so one query cache can be shared by several handles. Reads resume once the unfinished write is older than the timeout, and the next write re-enables the cache once every other unfinished write is older than the timeout. A transaction that runs longer than the timeout is treated as abandoned: raise it above your longest transaction, and use the same value in every process (the shortest one applies). - Keys left by 8.0 pre-releases. On Redis, keys matching
*#owner:*, document entries whose key ends in:<hash>#<epoch>and query-cache keys matching*:qcache:*#active:*are no longer read and can be deleted. On Redis a purged document keeps one key holding its generation, with no expiry: the key count grows with the number of document ids ever written, not with the number of writes. purgeCachedCollection()also invalidates the collection's cachedfind()results, andpurgeCachedCollection('_metadata')reads the collection list and purges each cached definition.- With a query cache installed, a cache error in
find()logs a warning and reads the database, asgetDocument()does. purgeCachedQueries()also purges thefind()query cache.Database::purgeCachedQueries($collection, $namespace = null)rotates thewithCache()region undergetQueryCacheKey()as in 7.x. When a query cache is installed withsetQueryCache(), it now also invalidates the collection'sfind()results in that namespace. It returnsfalseif either purge fails. It does not throw for a cache failure: the error is logged as a warning.delete()flushes the whole cache.Database::delete()flushes the cache it was given, as in 7.x. On a cache shared with other databases or applications (one Redis), that removes their entries too: give each database handle a cache of its own, or delete databases throughupdate()and a cleanup of your own where that matters.- Cache keys.
getCacheKeys(),getCacheBaseKeys()andgetQueryCacheKey()stay public.getQueryCacheField()is internal. - Filters that belong to one handle go in the
Databaseconstructor's$filtersor on aFilter\Registry(see Filters).
Database::addFilter(string $name, callable $encode, callable $decode) still registers a filter for every handle in
the process; its callbacks receive the value, the document and the database. A filter that belongs to one handle is
a Utopia\Database\Filter\Codec (name(), encode(mixed $value), decode(mixed $value)), which receives only the
value; 7.x passed constructor filters the document and the database as well. Filter\Callback builds one from two
closures.
| 7.x | 8.0 |
|---|---|
new Database($adapter, $cache, ['name' => ['encode' => $encode, 'decode' => $decode]]) |
new Database($adapter, $cache, [new Filter\Callback('name', $encode, $decode)]), a list of Filter\Codec |
getInstanceFilters() |
The codecs given to the constructor, or getFilters(): Filter\Registry |
// 7.x
$database = new Database($adapter, $cache, [
'trim' => [
'encode' => fn (mixed $value) => \trim($value),
'decode' => fn (mixed $value) => $value,
],
]);
// 8.0
$database = new Database($adapter, $cache, [
new Filter\Callback('trim', fn (mixed $value) => \trim($value), fn (mixed $value) => $value),
]);setFilters(Filter\Registry $filters): staticgives a handle a registry of codecs, which several handles can share;Filter\Registry::register(Filter\Codec $codec): static,get(string $name)andhas(string $name)manage it. A codec takes precedence over a global filter of the same name, and the constructor's codecs over both.- A codec named after a built-in filter (
Utopia\Database\Filter:json,datetime,point,linestring,polygon,vector,object) throwsException\Duplicate. A codec that needs the document or the database is a global filter: register it withaddFilter(). - Cache keys tell codecs apart by class. A codec class whose instances encode differently implements
Filter\Signed(signature(): string) so each instance gets its own key;Filter\Callbackdoes. - List a filter in an attribute's
filters, by name or as aFiltercase.
This section matters if you check adapter capabilities, read adapter limits, subclass an adapter or write your own. docs/add-new-adapter.md describes the contract a new adapter implements.
The 51 getSupportFor*() methods of Adapter are removed. A behaviour flag is a Utopia\Database\Capability case
checked with $adapter->supports(Capability::X). A group of methods an adapter may or may not implement is an
optional Utopia\Database\Adapter\Feature\* interface, checked with $adapter->hasFeature(Feature\X::class). Use
hasFeature(), never instanceof: Adapter\Pool forwards the optional feature methods to the adapter it borrows
without implementing their interfaces, so only hasFeature() answers correctly for a pooled adapter.
$database->profile() answers both without asking the adapter again (see Limits and profile).
| 7.x | 8.0 |
|---|---|
getSupportForAlterLocks() |
supports(Capability::AlterLock) |
getSupportForAttributeResizing() |
supports(Capability::AttributeResizing) |
getSupportForAttributes() |
supports(Capability::DefinedAttributes) |
getSupportForCaching() |
supports(Capability::Caching) |
getSupportForCastIndexArray() |
supports(Capability::IndexArrayCast) |
getSupportForCasting() |
! hasFeature(Feature\Casting::class): the library casts the values of every adapter that does not implement Feature\Casting |
getSupportForFulltextIndex() |
supports(Capability::IndexFulltext) |
getSupportForFulltextWildcardIndex() |
supports(Capability::IndexFulltextWildcard) |
getSupportForGetConnectionId(), getSupportForHostname(), getSupportForReconnection() |
hasFeature(Feature\Connection::class) |
getSupportForIdenticalIndexes() |
supports(Capability::IndexIdentical) |
getSupportForIndex() |
supports(Capability::IndexKey) |
getSupportForIndexArray() |
supports(Capability::IndexArray) |
getSupportForIntegerBooleans() |
supports(Capability::IntegerBooleans) |
getSupportForInternalCasting(), getSupportForUTCCasting() |
hasFeature(Feature\Casting::class) |
getSupportForMultipleFulltextIndexes() |
supports(Capability::IndexFulltextMultiple) |
getSupportForNestedTransactions() |
supports(Capability::TransactionNested) |
getSupportForObject() |
supports(Capability::Objects) |
getSupportForObjectIndexes() |
supports(Capability::IndexObject) |
getSupportForOperators() |
supports(Capability::Operators) |
getSupportForOrderRandom() |
supports(Capability::OrderRandom) |
getSupportForRelationships() |
hasFeature(Feature\Relationships::class) |
getSupportForSchemaAttributes(), getSupportForSchemaIndexes() |
supports(Capability::SchemaIntrospection) |
getSupportForSchemas() |
supports(Capability::Schemas) |
getSupportForSpatialAttributes() |
hasFeature(Feature\Spatial::class) |
getSupportForSpatialAxisOrder() |
supports(Capability::SpatialAxisOrder) |
getSupportForSpatialIndexNull() |
supports(Capability::IndexSpatialNull) |
getSupportForSpatialIndexOrder() |
supports(Capability::IndexSpatialOrder) |
getSupportForTTLIndexes() |
supports(Capability::IndexTtl) |
getSupportForTimeouts() |
hasFeature(Feature\Timeouts::class) |
getSupportForTransactionRetries() |
supports(Capability::TransactionRetries) |
getSupportForTrigramIndex() |
supports(Capability::IndexTrigram) |
getSupportForUniqueIndex() |
supports(Capability::IndexUnique) |
getSupportForUnsignedBigInt() |
supports(Capability::UnsignedBigInt) |
getSupportForUpdateLock() |
supports(Capability::UpdateLock) |
getSupportForUpsertOnUniqueIndex() |
supports(Capability::UpsertOnUniqueIndex) |
getSupportForUpserts() |
hasFeature(Feature\Upserts::class) |
getSupportForVectors() |
supports(Capability::Vectors) |
getSupportNonUtfCharacters() |
supports(Capability::NonUtfCharacters) |
getSupportForBatchCreateAttributes(), getSupportForBatchOperations(), getSupportForBoundaryInclusiveContains(), getSupportForCacheSkipOnFailure(), getSupportForDistanceBetweenMultiDimensionGeometryInMeters(), getSupportForJSONOverlaps(), getSupportForNumericCasting(), getSupportForOptionalSpatialAttributeWithExistingRows(), getSupportForPCRERegex(), getSupportForPOSIXRegex(), getSupportForQueryContains(), getSupportForRegex() |
No replacement: every adapter behaves the same way, or nothing branches on the answer |
Capability::Joins and Capability::Aggregations are new. Capability::SchemaIntrospection is declared by MariaDB,
MySQL, SQLite and PostgreSQL. Every case is declared by at least one adapter. To list what an adapter reports, call
$adapter->capabilities().
The optional features:
| Interface | Methods | Implemented by |
|---|---|---|
Feature\Casting |
castBefore(Document $collection, Document $document): Document, castAfter(Document $collection, array $documents): array, castDatetime(string $value): mixed |
MongoDB |
Feature\Connection |
ping(): bool, reconnect(): void, id(): string, hostname(): string |
the SQL adapters, MongoDB, Redis |
Feature\QueryBuilder |
builder(): Builder, whose from() takes a collection id, schema(): Schema |
the SQL adapters |
Feature\RawQuery |
rawQuery(string $query, array $bindings = []): array, rawMutation(string $query, array $bindings = []): int |
the SQL adapters |
Feature\Relationships |
createRelationship(string $collection, Relationship $relationship): bool, updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update): bool, deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side): bool |
the SQL adapters, MongoDB, Memory, Redis |
Feature\Schemaless |
setSchemaless(bool $schemaless): static, isSchemaless(): bool |
MongoDB |
Feature\Spatial |
encode(mixed $value, ColumnType $type): string, decode(string $value, ColumnType $type): array |
MariaDB, MySQL, PostgreSQL |
Feature\Timeouts |
setTimeout(int $milliseconds, Event $event = Event::All): void, clearTimeout(Event $event = Event::All): void, getTimeout(Event $event = Event::All): int |
MariaDB, MySQL, PostgreSQL, MongoDB, Pool |
Feature\Upserts |
upsertDocument(Document $collection, Change $change): Document, upsertDocuments(Document $collection, array $changes, ?string $increase = null): array |
the SQL adapters, MongoDB, Redis |
Change is final readonly with public old and new; 7.x's getOld(), setOld(), getNew() and setNew() are
removed.
// 7.x
if ($adapter->getSupportForFulltextIndex() && $adapter->getSupportForTimeouts()) {
$adapter->setTimeout(5000);
}
$limit = $adapter->getLimitForAttributes();
// 8.0
if ($adapter->supports(Capability::IndexFulltext) && $adapter->hasFeature(Feature\Timeouts::class)) {
$database->setTimeout(5000);
}
$limit = $adapter->limits()->attributes;The 16 per-limit getters of Adapter are one Adapter\Limits value, $adapter->limits():
| 7.x | Limits property |
|---|---|
getLimitForString(), getMaxVarcharLength() |
string, varchar |
getLimitForInt(), getLimitForBigInt() |
integer, bigInteger |
getLimitForAttributes(), getLimitForIndexes() |
attributes, indexes |
getCountOfDefaultAttributes(), getCountOfDefaultIndexes() |
defaultAttributes, defaultIndexes |
getMaxIndexLength(), getMaxUIDLength(), getDocumentSizeLimit() |
indexLength, uidLength, documentSize |
getMinDateTime(), getMaxDateTime() |
minDateTime, maxDateTime |
getIdAttributeType() |
idType (a ColumnType) |
getKeywords(), getInternalIndexesKeys() |
keywords, internalIndexKeys |
Database keeps getLimitForAttributes(), getLimitForIndexes(), getIdAttributeType() and getMaxUidLength()
(7.x adapters spelled it getMaxUIDLength()), all read from limits().
$database->profile(): Adapter\Profile snapshots the adapter's limits, capabilities, features, sharedTables
and migrating once per Database, and is rebuilt by setSharedTables(), setMigrating() and setSchemaless().
Its supports() and hasFeature() answer without a call into the adapter, except
supports(Capability::DefinedAttributes), which it asks the adapter every time, because a pooled connection's
schema mode can change. The validators take it (see Validators and helpers).
setSupportForAttributes(bool $support) is removed from every adapter. Switch MongoDB's schemaless mode with
$database->setSchemaless(! $support) (Feature\Schemaless), which also rebuilds the profile;
setSchemaless(true) throws on an adapter that always enforces its attributes. Calling the adapter's
setSchemaless() directly leaves the Database profile stale. Adapter\Pool puts every borrowed connection in the
mode.
ping(),reconnect(),getConnectionId()andgetHostname()areFeature\Connection'sping(),reconnect(),id()andhostname(). Memory has no connection.Database::ping(),reconnect(),getConnectionId()andgetHostname()forward them.- SQLite and MongoDB report their handle's
spl_object_id()as the connection id, which is unique only within the process; Redis reports'0'. - The protected
SQL::getPDO()is removed:getDriver(): objectreturns the PDO (or proxy).getDriver()returns the adapter itself on Memory and the client on Redis. SQL::getPDOAttributes()is removed. Pass PDO attributes yourself when you connect (ATTR_ERRMODE => ERRMODE_EXCEPTION,ATTR_DEFAULT_FETCH_MODE => FETCH_ASSOC,ATTR_EMULATE_PREPARES => true,ATTR_STRINGIFY_FETCHES => true, and a timeout as you need).Adapter::clearTimeouts()is removed:clearTimeout(Event::All)clears every event.
-
The abstract methods of
Adapterare the whole mandatory contract. 8.0 pre-releases also declared them in sixFeatureinterfaces (Attributes,Collections,Databases,Documents,Indexes,Transactions), which are removed. Report optional behaviour by overridingcapabilities(), and implement the optionalFeatureinterfaces your adapter supports. -
Adapter\SQLitenow extendsAdapter\SQLinstead ofAdapter\MariaDB. A check like$adapter instanceof MariaDBno longer matches SQLite: check capabilities and features instead. The MariaDB methods SQLite inherited in 7.x, such assetTimeout()andgetViolatedKey(), are no longer available on it. -
Changed adapter signatures:
7.x 8.0 exists(string $database, ?string $collection = null): boolexists(string $database): boolandcollectionExists(string $database, string $collection): bool— update(string $name, string $new): bool(abstract; see Renaming a database)createCollection(string $name, array $attributes = [], array $indexes = [])createCollection(string $collection, array $attributes = [], array $indexes = [])with lists ofAttributeandIndexdeleteCollection(string $id)deleteCollection(string $collection)createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false)createAttribute(string $collection, Attribute $attribute)updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false)updateAttribute(string $collection, string $key, Attribute $attribute); the target key is$attribute->keydeleteAttribute(string $collection, string $id)deleteAttribute(string $collection, string $key)createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1)createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = [])deleteIndex(string $collection, string $id)deleteIndex(string $collection, string $key)createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = '')Feature\Relationships::createRelationship(string $collection, Relationship $relationship)updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null)Feature\Relationships::updateRelationship(string $collection, Relationship $relationship, RelationshipSide $side, RelationshipUpdate $update)deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side)Feature\Relationships::deleteRelationship(string $collection, Relationship $relationship, RelationshipSide $side)deleteDocument(string $collection, string $id),deleteDocuments(string $collection, ...),increaseDocumentAttribute(string $collection, ...),getSequences(string $collection, ...)Take the collection Document, like every other document methodupdateDocuments(Document $collection, Document $updates, array $documents)updateDocuments(Document $collection, Document $updates, array $documents, array $skipPermissions = []): the ids whose permissions the update keepsupsertDocuments(Document $collection, string $attribute, array $changes)Feature\Upserts::upsertDocuments(Document $collection, array $changes, ?string $increase = null), plusupsertDocument(Document $collection, Change $change)getSchemaAttributes(string $collection): array,getSchemaIndexes(string $collection): arrayAbstract, returning list<Schema\Column>andlist<Schema\Index>getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): stringgetColumnType(Attribute $attribute): ?string(abstract;nullon non-SQL adapters)castingBefore(),castingAfter(Document $collection, Document $document),setUTCDatetime()Feature\Casting::castBefore(),castAfter(Document $collection, array $documents)over a page,castDatetime()decodePoint(),decodeLinestring(),decodePolygon()Feature\Spatial::encode()anddecode(string $value, ColumnType $type); the per-shape decoders are protectedfind(..., string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ)find(..., CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read);$orderTypesholdsOrderDirectioncasessetTimeout(int $milliseconds, string $event = Database::EVENT_ALL),clearTimeout(string $event),getTimeout(): intFeature\Timeouts, withEvent $event = Event::Allon all threeincreaseDocumentAttribute(..., int|float $value, ..., int|float|null $min = null, int|float|null $max = null)Numbers may also be numeric strings ( string|int|float), for unsigned 64-bit valuessupports()and the getters taking a$name/$idParameters named $capability,$collectionand$keySQL::__construct(mixed $pdo)SQL::__construct(object $pdo): aUtopia\Database\PDO, a PDO-compatible proxy or a nativePDOSQL::deleteAttribute(string $collection, string $id, bool $array = false)deleteAttribute(string $collection, string $key)SQL::execute(mixed $statement)execute(mixed $statement, ?Event $event = null), onAdapter\SQLonlySQL::getSQLType(string $type, ...)getSqlType(), takingColumnTypecasesSQL::getOperatorSQL(string $column, Operator $operator, array &$binds)getOperatorSql(string $column, Operator $operator, int &$bindIndex)setDatabase(),setSharedTables(),setTenant(),setTenantPerDocument():boolReturn static -
Protected and public methods use camel-cased acronyms and full words. Rename your overrides:
7.x 8.0 getSQLTable(),getSQLTableRaw()qualifyTable(string $database, string $namespace, string $name):getTable()andgetTableRaw()are final and both derive from itgetSQLType(),getSQLIndex(),getSpatialSQLType(),getOperatorSQL(),getPDOType(),getSQLReadableDistance(),getFTS5Value()getSqlType(),getSqlIndex(),getSpatialSqlType()(protected on MySQL too),getOperatorSql(),getPdoType(),getSqlReadableDistance(),getFts5Value()convertArrayToWKT(),isExtendedISODatetime(),convertUTCDateToString()convertArrayToWkt(),isExtendedIsoDatetime(string $value),convertUtcDateToString()bindOperatorParams(),getIdentifierQuoteChar()bindOperatorParameters(),getIdentifierQuote()getSpatialGeomFromText(),getSpatialAxisOrderSpec()getSpatialGeometryFromText(),getSpatialAxisOrder()replaceChars()replaceCharacters()Redis::tx()Redis::transaction()getLockType()(SQL),listCollections()andgetTenantFilters()(MongoDB)Protected Parameters $stmt,$fn,$op,$attrs,$val,$quoteChar$statement,$callback,$operation,$attributes,$value,$quoteCharacter -
quote()andexecute()are declared onAdapter\SQLonly. Keywords and internal index keys areLimitsfields. -
Adapter::relaxAttributeRequired(string $collection, string $id): boolis new. The library calls it when an attribute becomes optional without a column change; it does nothing by default, and PostgreSQL drops the column'sNOT NULLthere. -
SQL::getSpatialColumnSrid(): ?int(protected) is new. It returns the SRID written into spatial column definitions, ornullfor a dialect that cannot declare one (MariaDB).getSpatialSqlType()follows it as well, so a dialect returningnulldeclares spatial columns without an SRID oncreateCollection(),createAttribute(),createAttributes()andupdateAttribute()alike. -
SQL::insertOrIgnore(SQLBuilder $builder): Statement,SQL::supportsInsertReturning(): boolandSQL::documentKeyColumns(): array(protected) are new. UnderignoreDuplicates()an adapter'screateDocuments()must return only the documents it inserted; the SQL adapters learn them fromRETURNING, or, wheresupportsInsertReturning()is false (MySQL), from reading the ids before and, when rows were skipped, after the insert. -
SQLdeclaresabstract protected function getColumnNames(string $collection): array(the table's physical column names, empty when the table is missing).SQL::renameAttribute()and the engines'updateAttribute()use it to complete a rename another tenant of a shared table already ran. -
SQL::getNullOrder(): OrderDirection(protected) returns the direction in which the engine sorts null before every other value (OrderDirection::Ascby default; the PostgreSQL adapter returnsOrderDirection::Desc). A SQL adapter for an engine that sorts nulls last in ascending order overrides it, or a cursor over a joined read skips or repeats rows holding null. -
Declare
Capability::TransactionNestedonly when a failed nested transaction rolls back to its savepoint and leaves the enclosing transaction open.Databasedrops thedocument_purgeevents of a failed nested call only on such adapters; without it they fire with the enclosing commit. -
Adapter::abandonTransaction(): void(protected, a no-op by default) is new. The outermostwithTransaction()calls it after every failed attempt, once the transaction counter no longer counts the attempt's transaction; an adapter whose connection can still hold part of it (a transaction lost with the connection, or one a failed rollback left open) ends it there. The SQL adapters roll back whatever the connection still reports. -
Adapter::isIgnoringDuplicates(): boolandDatabase::isIgnoringDuplicates(): bool(protected) report whether the calling coroutine runs underignoreDuplicates(). -
Utopia\Database\PDOStatement::getQueryString(): stringreturns the wrapped statement'squeryStringwithout going through the magic__get(). -
Adapter::withTenant($tenant, $callback)scopes the tenant to the calling coroutine.Database::withTenant()uses it and no longer callssetTenant(), so an adapter that overridessetTenant()to react to tenant changes has to key such state bygetTenant()instead. -
Write hooks and the tenant hook are registered by the library.
hasTenantHook()andhasPermissionHook()are removed;getTenantHook()andgetWriteHooks()are internal.removeWriteHook()also takes an instance. -
An adapter's
getDocument()must not change the$collectionit is given: forDatabase::METADATAit is one definition shared by the whole process.
Methods that went with a feature exist only on the adapters that implement it, and the limit getters are gone from
every adapter. Besides the getSupportFor*() methods and the per-limit getters, these public 7.x methods are gone:
| Adapter | Removed public methods | Use instead |
|---|---|---|
| Every adapter | getConnectionId(), getHostname(), ping(), reconnect() on the base class |
Feature\Connection (id(), hostname(), ping(), reconnect()) where implemented; the Database forwards |
| Every adapter | setSupportForAttributes(), getSupportNonUtfCharacters(), clearTimeouts(), enableAlterLocks(), getKeywords(), getInternalIndexesKeys(), getTenantQuery(), before(), setDebug(), getDebug(), resetDebug() |
See the sections above |
| Every adapter | getColumnType(string $type, ...) |
getColumnType(Attribute $attribute) |
| SQL adapters (MariaDB, MySQL, PostgreSQL, SQLite) | castingBefore(), castingAfter(), setUTCDatetime(), decodePoint(), decodeLinestring(), decodePolygon(), getPDOAttributes(), getSpatialTypeFromWKT(), getLikeOperator(), getRegexOperator(), getSQLConditions() |
Library casting; encode()/decode(); your own PDO attributes; the query builders |
| MariaDB | getSpatialSQLType() (public in 7.4) |
Protected getSpatialSqlType() |
| MySQL | getSpatialSQLType() (public) |
Protected getSpatialSqlType() |
| SQLite | setEmulateMySQL(), getEmulateMySQL(), and the MariaDB methods it inherited (setTimeout(), getViolatedKey(), ...) |
A subclass with protected bool $emulateMySQL = true |
| MongoDB | getConnectionId() is now id(); getSchemaAttributes() and getSchemaIndexes() return []; castingBefore(), castingAfter(), setUTCDatetime(); listCollections() and getTenantFilters() (now protected); setSupportForAttributes() |
id(); no introspection; Feature\Casting; Database::listCollections(); setSchemaless() |
| Memory | getConnectionId(), ping(), reconnect(), getHostname(), upsertDocuments() |
None: Memory has no connection and no upserts (Feature\Upserts) |
| Memory, Redis | castingBefore(), castingAfter(), setUTCDatetime(), decodePoint(), decodeLinestring(), decodePolygon() |
Library casting; no spatial attributes |
| Redis | tx() |
transaction() |
Adapter\Pool subclasses override its protected extension points. These signatures are stable in 8.0; mark every
override #[\Override] so that a later rename fails loudly instead of being skipped. Renamed against 7.x and the
8.0 pre-releases:
| Before | 8.0 |
|---|---|
delegate(string $method, array $args): mixed |
delegate(string $method, array $arguments): mixed (public) |
delegateFeature(string $feature, string $method, array $args) |
delegateFeature(string $feature, string $method, array $arguments); $feature names an optional feature |
borrowAndInvoke(string $method, array $args, ?string $feature = null) |
borrowAndInvoke(string $method, array $arguments, ?string $feature = null) |
invokeDelegated(Adapter $adapter, string $method, array $args, ?string $feature = null) |
invokeDelegated(Adapter $adapter, string $method, array $arguments, ?string $feature = null) |
syncBorrowedAdapter(Adapter $adapter) |
syncBorrowed(Adapter $adapter) |
releaseBorrowedAdapter(Adapter $adapter) |
releaseBorrowed(Adapter $adapter) |
getHostname(): string, ping(): bool |
hostname(): string, ping(): bool (Feature\Connection methods, which Pool forwards) |
pin(), withTransaction(), inTransaction() and getReadConcurrency() are unchanged. The protected
Pool::$pinnedAdapter property is removed (see Pools and profiling). Pool implements
Feature\Timeouts itself and forwards every other optional feature through delegateFeature().
syncBorrowed() sets the database, namespace, shared-tables and tenant-per-document modes, tenant, authorization,
locks, schemaless mode and timeouts on the connection on every delegated call. It replays metadata, the profiler,
transforms and write hooks only when the connection's differ from the pool's: resetMetadata() and setMetadata()
when its getMetadata() is not identical, setProfiler() when it holds another profiler, and resetTransforms()
and addTransform() when its transforms are not the same instances under the same names in the same order. A
Pool subclass or a custom connection adapter must not rely on those methods being called on every delegated call.
releaseBorrowed() still clears a borrowed connection's profiler after each checkout.
Queries now compile through the utopia-php/query builders (builder()), transforms through Hook\Transform, and
tenant and permission conditions through the hooks in Utopia\Database\Adapter\SQL\Hook and
Utopia\Database\Hook\Mongo. The 7.x methods behind the old string-building path are removed. Nothing in 8.0 calls them, so an adapter subclass that overrides or
calls one must drop the override or the call.
| Removed | Visibility in 7.x | Replacement |
|---|---|---|
SQL::getSQLConditions(array $queries, array &$binds, string $separator = 'AND', ?string $forCollection = null) |
public | The adapter's query builder (builder()) |
SQL::getSQLConditionsForCollection() |
protected | The same |
getSQLCondition(Query $query, array &$binds, ?string $forCollection = null) on SQL (abstract), MariaDB, Postgres and SQLite |
protected | The same |
SQL::getSQLOperator() |
protected | The same |
handleSpatialQueries() on MariaDB and Postgres |
protected | The same |
handleDistanceSpatialQueries() on MariaDB, MySQL and Postgres |
protected | The same |
Postgres::handleObjectQueries() |
protected | The same |
SQLite::getLikeCondition() |
protected | The same. SQLite's LIKE ... ESCAPE lives in Utopia\Database\Builder\SQLite |
getSQLPermissionsCondition() on SQL and Postgres |
protected | The permission hooks in Utopia\Database\Adapter\SQL\Hook\Permission (Filter, Join, OuterJoin) |
getSQLVectorDistance() on SQL and Postgres |
protected | The query builder |
Adapter::getTenantQuery() (abstract) and its implementations on SQL, Memory, Redis, Pool and Mongo |
public | Adapter\SQL\Hook\Tenant\Filter (SQL) and Hook\Mongo\Tenant (MongoDB) |
getInsertKeyword() on SQL, Postgres and SQLite, and getInsertSuffix() and getInsertPermissionsSuffix() on SQL and Postgres |
protected | SQL::insertOrIgnore(SQLBuilder $builder): Statement, which Postgres overrides to name the id as the conflict target |
getUpsertStatement() on SQL (abstract), MariaDB, Postgres and SQLite |
protected, public on MariaDB and SQLite |
The same |
SQL::registerOperatorBind() |
protected | getOperatorSql() binds operator values itself |
SQL::getFulltextValue() and Postgres::getFulltextValue() |
protected | utopia-php/query's builders normalize search terms (compileSearchExpression()) |
Adapter::before() and Adapter::trigger() |
public, protected | Hook\Transform, registered with Database::addHook() |
SQL::getLikeOperator(), SQL::getRegexOperator(), Postgres::getLikeOperator() and Postgres::getRegexOperator() |
public | The query builders emit LIKE/ILIKE and REGEXP/~ |
Adapter::getAttributeProjection() (abstract) and its implementations on SQL, Memory, Redis and Pool (Mongo keeps a private one) |
protected | The query builders build the projection |
getRandomOrder() on SQL (abstract), MariaDB, Postgres and SQLite |
protected | The query builders' compileRandom() emits RAND()/RANDOM() for Query::orderRandom() |
SQL::getSpatialTypeFromWKT() |
public | None. The type is the text before the first ( of the WKT, lower-cased |
getSQLIndexType() on SQL and SQLite |
protected | Each adapter's createIndex(), which builds the whole index statement |
Postgres::getSQLSchema() |
protected | getTable(), which qualifies the table with the schema |
Postgres::encodeArray() and Postgres::decodeArray() |
protected | None. Array attributes are JSONB columns |
Memory::unregisterRelationshipField() |
protected | None |
escapeWildcards() is kept, and so is the public helper Query::isSpatialAttribute(), even where nothing in the
library calls it any more.
The SQLite adapter builds its queries with Utopia\Database\Builder\SQLite, not Utopia\Query\Builder\SQLite. SQLite
has no default LIKE escape character, and this builder adds ESCAPE '\' so that startsWith, endsWith,
containsString, containsAny, containsAll, notContains, notStartsWith and notEndsWith match _, % and
\ literally, as they did in 7.x. If you subclass Utopia\Database\Adapter\SQLite and override dialectBuilder(),
return Utopia\Database\Builder\SQLite or a subclass of it. A plain Utopia\Query\Builder\SQLite treats _ and %
as wildcards and a backslash as a literal character.
The protected dialectBuilder() returns the builder a SQL adapter builds every statement with, unscoped. The final
builder() scopes it with the adapter's scope() for Database::from(), and the adapter's own statements scope it
with the tenants they read. Its from() takes a collection id and reads the collection's table, mapping attributes to
columns and, under shared tables, keeping the statement to the tenant, so the builder must implement
Utopia\Database\Builder\Scoping with the Utopia\Database\Builder\ScopesCollections trait:
return new MyBuilder();. The table names come from qualifyTable(); getTable() and getTableRaw() are final, so
an adapter that stores its tables under other names overrides qualifyTable().
-
Session settings and reconnects.
Utopia\Database\PDOreconnects on its own when a call outside a transaction finds the connection gone, and retries the call on the new connection. A new connection starts a new database session, so session state set earlier withexec()is gone after a reconnect. Set session state that must last throughconfigure()instead:$pdo->configure('time_zone', "SET time_zone = '+00:00'");
configure(string $setting, string $statement)runs the statement now and again on every connection a later reconnect opens, before the retried call runs there. A later statement for the same$settingreplaces the earlier one. Attributes set withsetAttribute()after connecting are replayed the same way, before the session statements. If a statement fails on the new connection,reconnect()throws and the wrapper keeps the old connection, so the next call reconnects again instead of running on an unconfigured session. The MariaDB and MySQL adapters keepsetTimeout()this way, so a timeout also bounds the statement that triggered the reconnect. Behind Swoole'sPDOProxy, which reconnects without replaying session state, the adapter sets the timeout again before its next statement; the statement the proxy itself retries after its reconnect runs without it. -
Lost-connection detection.
Connection::hasError()decides by the driver's error first: MySQL and MariaDB errors 1053, 2002, 2006, 2013 and 4031, SQLSTATE class08, and PostgreSQL57P01to57P05. Statement timeouts (MariaDB 1969, MySQL 3024, PostgreSQL 57014) are not lost connections and are never retried. The message fallback carries Swoole 6.2'sDetectsLostConnectionslist, so a PHP without ext-swoole, or withswoole.enable_library=Off, detects the same lost connections as one with it. -
Lost transactions. When the connection loses a transaction that
withTransaction()calls are nested in, the nested call and every enclosing call throwUtopia\Database\Exception\Transaction(Failed to execute transaction: the transaction was lost before it could commit) and are not retried. Nothing written in the lost transaction is committed. A nested call that fails transiently while its enclosing transaction holds is rolled back to its savepoint and rethrown, and the outermost call runs again; a top-level call that failed to begin, or whose work failed transiently, is still retried (see Transaction retries). A top-level commit that finds the connection no longer holds the transaction (a reconnect the callback did not surface) throwsException\Transactiontoo, and the callback is not run again: statements after such a reconnect may already have run on their own. Code that catches an expected exception (for exampleDuplicate) from a nested call and carries on no longer receives that exception when the transaction was lost underneath it: catchException\Transactionaround the outermost call and run the whole unit again. A callback that catches the nested call'sException\Transactionand returns fails the same way instead of reporting the lost work as committed. The outermost call ends what is left of the lost transaction on the connection before it throws, so the connection runs the next statement. A transaction the engine rolled back over a lock conflict is not lost: MariaDB and MySQL roll the whole transaction back, savepoints included, when a statement loses a deadlock (1213), or a lock wait timeout (1205) withinnodb_rollback_on_timeout. The nested calls rethrow thatException\Contentionunchanged, and the outermost call runs again, as in 7.x, because nothing of the attempt is stored. -
Statements after a lost transaction. When
Utopia\Database\PDOreconnects because a statement inside a transaction found the connection gone, it rethrows and then refuses every statement (exec(),query(),prepare(),beginTransaction(),commit()) with aPDOExceptionuntil the transaction is ended withrollBack(), aROLLBACKstatement orreconnect(). ArollBack()that itself finds the connection gone ends the transaction too: it rethrows and refuses nothing after it.inTransaction()reports the transaction until then, and each refusal'sgetPrevious()is the lost connection error. Code that catches the connection error insidewithTransaction()and carries on no longer writes on the new connection in autocommit: the transaction is rolled back and, at the top level, runs again.
8.0 changes the partial filter of the key and unique indexes the MongoDB adapter creates, and fixes two defects that 7.x and the 8.0 pre-releases share:
- Unique indexes:
createIndex()gave every attribute the filter{attr: {$exists: true, $type: 'string'}}, whatever its type, so a unique index on an integer, big integer, float, boolean or datetime attribute covered no document and accepted duplicates.createCollection()requiredintfor integers,longfor big integers anddoublefor floats, which left out integers past 32 bits, big integers inside 32 bits and floats stored as integers. 8.0 requires every type a value of the attribute can be stored as, and for an attribute without a declared type (an internal attribute such as$createdAt, or any attribute of a schemaless collection) every BSON type the driver writes but null, arrays, binary data and timestamps included, where 7.4.1 covers strings, numbers, booleans, dates and objects only. - Key indexes: both paths added a
$typeclause, and MongoDB uses a partial index only for queries that imply its filter, which a filter on a value never does for$type. No query used these indexes, whatever the attribute type. 8.0 gives a key index{first attribute: {$exists: true}}only, which a filter on a non-null value of that attribute implies, so a single or compound key index serves any filter on its first attribute.
Existing indexes keep the filter they were created with. The library does not rebuild them; run this step once per database after upgrading. It covers both changes, so one rebuild is enough.
-
For every collection, read its
indexesandattributesfrom the database's metadata collection (Database::getCollection()), and pick:- every index of type
key, whatever its attributes' types; - every index of type
uniquewith at least one attribute of typeinteger,biginteger(stored asbigint),float,double,booleanordatetime, or one without a declared type (an internal attribute, or any attribute in a schemaless database).
- every index of type
-
For a
uniqueindex, look for duplicates first, because the rebuilt index enforces uniqueness and its creation fails while duplicates exist:createIndex()throwsException\Unique, and only a raw driver call sees error11000. Group the documents that hold a value for every attribute of the index by those attributes (and by_tenantunder shared tables), and list the groups with more than one document, for example:db.getCollection('<namespace>_<collection>').aggregate([ { $match: { <field>: { $exists: true, $ne: null } } }, { $group: { _id: { tenant: '$_tenant', value: '$<field>' }, count: { $sum: 1 }, ids: { $push: '$_uid' } } }, { $match: { count: { $gt: 1 } } }, ])The stored field name is the attribute key with each
.written as__dot__. Resolve every duplicate (change or remove documents) before step 3. -
Drop and recreate the index with the same key, type, attributes, lengths and orders:
Database::deleteIndex($collection, $key)and thenDatabase::createIndex($collection, $index). Between the two calls the collection has no such index: a unique constraint is not enforced and queries do not use it, so run the step when the collection takes no writes. A key index needs no duplicate check.
Unique indexes whose attributes are all declared strings (string, varchar, text, mediumtext, longtext,
id, uuid7), fulltext and TTL indexes, and the internal _uid, _createdAt, _updatedAt and _permissions indexes
need no rebuild. You can tell a rebuilt index by its partialFilterExpression (db.<collection>.getIndexes()): a
key index names only its first field, with no $type, and a unique index on an integer attribute has
$type: ['int', 'long'].
Adapter\Mongo::createCollection() of a collection that already exists throws Exception\Duplicate, as the SQL
adapters do for an existing table. Under shared tables, and for the metadata collection, it first creates the
collection's indexes on the existing collection, so a collection another tenant or process created without them gets
them, and then throws Duplicate; Database::createCollection() adopts such a collection and stores its definition.
Outside shared tables Database::createCollection() reports the existing collection as Duplicate and leaves it as
it is. A collection whose indexes conflict with ones it already has under the same name (code 85) throws
Exception\Index instead of Duplicate. Under shared tables every tenant's collection of an id shares one set of
indexes, so a tenant that declares an index under a name another tenant uses with other fields fails with the
server's index-spec conflict (code 86). A collection the create made but could not give its indexes is dropped,
except inside a transaction, whose abort discards it, and under shared tables, where another tenant may already use
it; a failed create never drops a shared collection.
Adapter\Mongo::exists() reports a database only when the server lists it; it returned true for every name.
collectionExists() looks in the database it is given.
-
Document ids compare case-insensitively.
getDocument(),equal('$id', ...),notEqual('$id', ...)and joins on$idcompare_uidwithCOLLATE NOCASE, matching the unique index and MariaDB:getDocument('Doc')findsdoc. Code that relied on SQLite tellingdocandDocapart must not: the unique index never allowed both. -
Shared-table files created by 7.x or the 8.0 pre-releases keep unique indexes that declare
_tenant COLLATE NOCASE. Lookups still use them onceANALYZEhas run (analyzeCollection()); without statistics, joins walk the tenant's rows once per joined alias. Recreate them once per collection (uniqueness is unchanged,_tenantis an integer;{tenant}is the tenant segment of the existing index names):DROP INDEX `{namespace}_{tenant}_{collection}__index1`; CREATE UNIQUE INDEX `{namespace}_{tenant}_{collection}__index1` ON `{namespace}_{collection}` (`_tenant`, `_uid` COLLATE NOCASE); DROP INDEX `{namespace}_{tenant}_{collection}_perms__index_1`; CREATE UNIQUE INDEX `{namespace}_{tenant}_{collection}_perms__index_1` ON `{namespace}_{collection}_perms` (`_tenant`, `_document` COLLATE NOCASE, `_type` COLLATE NOCASE, `_permission` COLLATE NOCASE);
Plain-table files need nothing.
-
Regex.
Query::regex()works on SQLite through the adapter'sREGEXPfunction, whichUtopia\Database\PDOandPdo\Sqliteconnections register; patterns are PCRE (preg_match, case-sensitive,uflag). -
getSchemaIndexes()returns index ids. EachSchema\Index'snameis the index id (email,_index1), not the SQLite object name ({namespace}_{tenant}_{collection}_email) or the FTS5 table (…_{hash}_fts). To read the physical names, querysqlite_master. Under shared tables the list includes indexes other tenants created on the shared table, since they cover every tenant's rows.
All tenants' collections with the same id share one physical table. Several tenants declaring the same collection id with the same attributes is the normal case.
- Conflicting attributes and indexes are refused.
createAttribute()andcreateAttributes()over a column another tenant created reuse it when the column type matches, and throwUtopia\Database\Exception\Duplicate(Attribute exists in the shared table with another type) when it does not (on PostgreSQL its subclassException\Mismatch); a refused batch creates none of its columns. Before, the column was dropped with the other tenant's values.createIndex()over an index another tenant uses throwsDuplicate(Index exists in the shared table with another definition) when the definition differs. Outside shared tables an orphaned column or index (left by a partial failure) is still dropped and recreated. While migrating shared tables (setMigrating(true)) neither check runs, as before. - Renames. A rename is physical: the first tenant to rename an attribute of a shared collection id renames the
column for every tenant, and tenants whose metadata still has the old key read it as null until their own rename
runs. Run the rename for every tenant (as project-by-project migrations do). A later tenant's rename
(
renameAttribute(), orupdateAttribute()with a new key) completes without DDL when the old column is gone and the new one exists; otherwise the engine decides (the old column missing isNotFound, a new column beside the old one isDuplicate).
Database::setMetadata() values are written as /* key: value */ comments at the start of the SQL statements, as
in 7.x, ahead of the registered Transform hooks.
- Comments now precede every statement the SQL adapters prepare, including raw queries,
ping(),id(), schema introspection and SQLite's transaction statements. 7.x annotated only statements that went through an event transformation. Statements PDO issues itself (begin, commit, rollback,lastInsertId()), savepoints and the timeout session statements carry none. - A
Transformreceives the SQL with the comment block first. Do not anchor patterns on the statement keyword at the start of the string. resetMetadata()takes effect at once. In 7.x the last metadata transformation stayed registered after a reset.- Scalars and
Stringablevalues are rendered as text (trueas1,falseas an empty string, as in 7.x). Arrays,nulland other objects are rendered as JSON. In 7.x an array printedArraywith a warning and a non-Stringableobject threw. - Keys and values are rewritten where needed:
/*becomes/ *,*/becomes* /, control characters and U+2028/U+2029 become spaces, and invalid UTF-8 bytes are replaced with mbstring's substitute character (?by default).
-
onError().onError(callable $callback): staticcallbacks receive oneMirror\Failure, with themethodthat failed on the destination, itsevent(?Event: for exampleupsertDocumentreportsDocumentUpsert,createIndexesIndexesCreate,renameAttributeAttributeRenameandupdateDatabaseUpdate) and theerror. 7.x passed(string $action, \Throwable $error).// 7.x $mirror->onError(fn (string $action, \Throwable $error) => $logger->error($action, ['error' => $error])); // 8.0 $mirror->onError(fn (Failure $failure) => $logger->error($failure->method, ['error' => $failure->error]));
-
Write filters are
Utopia\Database\Mirror\Filter(wasMirroring\Filter); itsinit()isinitialize(). -
Hooks.
$mirror->addHook(new Relationships(...))keeps the given hook and attaches a copy to the source and one to the destination, so its configuration (prepare, a subclass) reaches both.removeHook()also removes a hook from the source and the destination.addLifecycleHook()is protected. -
Query cache. Install the query cache through the mirror:
$mirror->setQueryCache($queryCache)installs it on the source and the destination as well, so writes through the mirror invalidate the cache its reads use. AnInvalidatoradded with$mirror->addHook()is installed on the mirror and the source. -
Setters reach the wrapped databases. Besides
setDatabase(),setNamespace(),setSharedTables(),setTenant(),setMaxQueryValues(),setCache(),setAuthorization(), validation and the document-type setters, a mirror now forwardssetQueryCache(),setCacheName(),setGlobalCollections(),resetGlobalCollections(),setTenantPerDocument(),setCacheWriterTimeout(),setTimeout(),clearTimeout(),setMetadata(),resetMetadata(),setFiltering(),withFiltering(),skipFilters(),setLocks(),setProfiling(),setMigrating()andsetFilters()to its source and destination. In 7.x these changed the mirror alone. -
Timeouts on the destination. A destination that cannot apply a timeout (MariaDB applies it on the connection) is reported through
onError()with the methodsetTimeoutorclearTimeout; the call itself succeeds when the source applied it.setLocks()reports a destination failure the same way, with the methodsetLocks. -
create(). A destination that cannot create the database makescreate()throw, after the source has created it, so a mirror never reports a database its destination lacks. Every other call forwarded to the destination reports a destination failure throughonError()and returns the source's result. -
Profiling.
$mirror->setProfiling(true)enables profiling on the source and the destination, and$mirror->getProfiler()returns the source's profiler, which records the mirror's queries, because both use the source's adapter. The destination's queries are recorded by$mirror->getDestination()->getProfiler(). -
Scoped setters.
withTenant(),withPreserveDates(),withPreserveSequence(),withValidation(),skipValidation(),withFiltering()andskipFilters()called on a mirror open on the mirror, its source and its destination for the duration of the callback.skipRelationships(),skipRelationshipsExistCheck()andwithRequestTimestamp()open on the mirror and its source only; the destination applies the source's resulting documents with preserved dates.withRequestTimestamp()now runs its callback once; 7.x ran it once per database when a destination was set. -
Replication. Inside a coroutine (a Swoole server),
createDocuments(),updateDocuments(), the upserts,deleteDocument()anddeleteDocuments()return once the source write is done and replicate in a coroutine of their own; a destination failure reachesonError()later. Every other call that reaches the destination (createDocument(),updateDocument(),increaseDocumentAttribute(),decreaseDocumentAttribute(), the schema changes,create(),delete(),exists(),setTimeout()andclearTimeout()) applies there before it returns, after the replications queued before it. Outside a coroutine all replication finishes before the call returns. In 7.x every write replicated before returning.- Each replication runs under the state the caller had at the time of the call: authorization status and roles,
tenant, relationship and silence state and the toggles (see Coroutines), without the request
timestamp. A write made inside
skip()replicates under it even after the caller has left the scope. - A mirror applies its changes to the destination one at a time, in the order they were made through it, whichever
documents, related documents or schema they reach: a write never overtakes an earlier one, a schema change waits
for every queued replication, and two replications never share the destination's connection, so the destination
needs no
Adapter\Pool. A failed replication is reported toonError()and does not hold back later ones. The order holds per mirror; writes through two mirrors over the same destination are not ordered with each other. - A write through the mirror made while a replication applies, from
onError()or a write filter, is part of that replication and applies at once. - The write filters' document hooks (
beforeCreateDocument(), ...) run when the replication applies, in its coroutine. $mirror->awaitReplications(?int $timeout = null)returns once every replication queued so far has reached the destination or has been reported toonError(), or once$timeoutmilliseconds have passed, without an error.awaitReplications(0)returns at once, and a negative timeout throws. Call it before a worker stops, or queued replications are lost.delete()waits for them before it deletes the destination database.
- Each replication runs under the state the caller had at the time of the call: authorization status and roles,
tenant, relationship and silence state and the toggles (see Coroutines), without the request
timestamp. A write made inside
-
Authorization.
new Mirror($source, $destination)leaves the source's and the destination'sAuthorizationin place, and the mirror uses the source's, so roles andskip()scopes set on it apply to reads and writes through the mirror. In 7.x the mirror started with anAuthorizationof its own, which it also gave the source's adapter.$mirror->setAuthorization()sets one on the mirror, its source and its destination, as before. -
Write filters. A
nullreturn frombeforeCreateCollection(),beforeUpdateCollection(),beforeCreateAttribute(),beforeUpdateAttribute()orbeforeCreateIndex()skips that change on the destination, and a collection whose creation was skipped is not replicated. An exception from a filter hook is reported toonError()under the write's method and skips that replication; the source change stands. -
Decorators. Decorator hooks added through a mirror stay on the mirror: they decorate what its reads and writes return (including the documents bulk writes hand
onNext), and the destination receives undecorated documents.createDocument()returns the document written to the source, asupdateDocument()does, instead of the destination's copy. -
Upserts.
upsertDocument()and an increasingupsertDocuments()through a mirror now run on the source and are replicated to the destination, like a plainupsertDocuments(); in 7.x they were never replicated. Hooks registered through the mirror receivedocument_purgefor each upserted document,document_upsertforupsertDocument()anddocuments_upsertonce perupsertDocuments()call. A failed replication is reported toonError()with the methodupsertDocumentorupsertDocuments.
The validators that depended on adapter limits and capabilities take the Adapter\Profile of the Database
($database->profile()) instead of positional limits and booleans.
| 7.x | 8.0 |
|---|---|
new Validator\Attribute(array $attributes, array $schemaAttributes = [], int $maxAttributes = 0, int $maxWidth = 0, ... 14 more limits and booleans) |
new Validator\AttributeDefinition(array $attributes, Adapter\Profile $profile, array $schemaAttributes = [], ?\Closure $attributeCount = null, ?\Closure $attributeWidth = null, ?\Closure $filter = null) |
new Validator\Index(array $attributes, array $indexes, int $maxLength, array $reservedKeys = [], ... 15 booleans) |
new Validator\IndexDefinition(array $attributes, array $indexes, Adapter\Profile $profile) |
new Queries\Documents(array $attributes, array $indexes, string $idAttributeType, int $maxValuesCount = 5000, int $maxUIDLength = 36, \DateTime $minAllowedDate, \DateTime $maxAllowedDate, bool $supportForAttributes = true, bool $supportUnsignedBigInt = true) |
new Queries\Documents(array $attributes, array $indexes, Adapter\Profile $profile, int $maxValuesCount = 5000) |
new Queries\Document(array $attributes, bool $supportForAttributes = true) |
new Queries\Document(array $attributes, Adapter\Profile $profile, int $maxValuesCount = 5000) |
new Structure(Document $collection, string $idAttributeType, \DateTime $minAllowedDate, \DateTime $maxAllowedDate, bool $supportForAttributes = true, bool $supportUnsignedBigInt = true, ?Document $currentDocument = null) |
new Structure(Document $collection, Adapter\Profile $profile, ?Document $currentDocument = null, array $storedAttributes = []) |
new Query\Order(array $attributes = [], bool $supportForAttributes = true) |
new Query\Order(array $attributes = [], bool $supportForAttributes = true, bool $supportForOrderRandom = true) |
Validator\Queries, Validator\IndexedQueries |
Validator\Queries\Base, Validator\Queries\Indexed |
Validator\ObjectValidator |
Validator\ObjectValue |
// 7.x
$validator = new Index($attributes, $indexes, $adapter->getMaxIndexLength(), [], $adapter->getSupportForIndexArray(), ...);
// 8.0
$validator = new IndexDefinition($attributes, $indexes, $database->profile());Queries\DocumentsandQueries\Documentaccept joins, aggregations and$tenantexactly as the profile allows: joins withCapability::Joins, aggregate functions,groupBy,havinganddistinctwithCapability::Aggregations, andselect('$tenant')under shared tables.Utopia\Database\Validator\Query\Select,AggregateandGroupByaccept$tenantonly when constructed withsharedTables: true(third constructor argument). A validator built directly rejectsselect('$tenant')without it;Database::find()already rejected it without shared tables in 7.x.Query\OrderrefusesorderRandom()when constructed withsupportForOrderRandom: false.- The new
Utopia\Database\Validator\Query\Jointakes the main collection's attributes (new Join($attributes)) to check the columns of a join condition.new Join()accepts any column of the main collection.Validator\Query\Joined\CollectionandValidator\Query\Joined\Attributescheck the joined collections. Query\FilterdefaultssupportUnsignedBigInttotrue, as in 7.x.Validator\Query\Cursoraccepts aDocumentor a document id, and refuses an array.- The protected
Database::getDocumentsValidator(Document $collection, array $joinedCollections = [])is new in 8.0. - Changed validator signatures:
AttributeDefinition'scheck*()methods take anAttribute,IndexDefinition's take anIndex(checkTTLIndexes()ischeckTtlIndexes()),getRequiredFilters()andvalidateDefaultTypes()take aColumnType,Structure::addFormat(),getFormat()andhasFormat()(and those ofPartialStructure) take aColumnType,Query\Filter::isValidAttributeAndValues()takes aMethodcase, andValidator\Spatial::isWKTString()isisWktString().Validator\Operatortakes a trailingbool $supportUnsignedBigInt = true. EveryisValid()takesmixed $value: a call that names the argument after the 7.x parameter ($permissionsonPermissions,$rolesonRoles,$documentonStructureandPartialStructure,$inputonAuthorization) passesvalue:instead. Validator\PermissionsandPermission::aggregate()take their allowed permission types asPermissionTypecases; a list of strings throws aTypeError.Validator\Authorization\Inputtakes aPermissionTypecase, or the string of an action of your own such asexecute.Validator\Authorization's status is no longer aprotected bool $statusproperty; subclasses read and change it throughgetStatus(),setStatus()andskip().skip()is scoped to the calling coroutine and the coroutines it starts;setStatus(),enable(),disable()andreset()change the shared status unless called inside such a scope (see Coroutines).setDefaultStatus()is a constructor argument,new Authorization(bool $defaultStatus = true).restore()is internal.Validator\Structuretakesarray $storedAttributes = []: the attributes whose values are the stored ones, which it does not validate again, whatever their type, so a stored value a narrowed definition no longer admits never blocks an update of another attribute.Database::updateDocument()passes it. (8.0.1 validated them again, by mistake; 8.0.2 does not.)Database::convertQueries()takes an optionalarray $joinedCollections(join alias => collection). With it, filters onalias.attribute, the filters of join ON lists andhaving()conditions in the list are converted too; aggregates and selects in the list are left as they are. Without it the method converts as before.
Nothing in the library, Appwrite, Appwrite Cloud or utopia-php/migration calls these 7.x methods.
| Removed | Replacement |
|---|---|
Adapter\SQL::setFloatPrecision(int $precision) |
As in 7.x, a find() binds floats in fixed-point with 17 decimals and every other statement binds them as PHP writes them. A subclass can set the protected $floatPrecision property |
Adapter\SQLite::setEmulateMySQL(), getEmulateMySQL() |
A subclass sets the protected $emulateMySQL property to true |
Database::getInstanceFilters() |
The codecs given to the constructor, or getFilters() |
Mirror::getWriteFilters() |
The $filters given to the constructor. A subclass reads the protected $writeFilters property |
Validator\Structure::getFormats() |
Structure::hasFormat($name, $type) and Structure::getFormat($name, $type) |
Appwrite, Appwrite Cloud and utopia-php/migration built against the unreleased feat-query-lib branch. These names
from those builds changed before 8.0.0; none of them exists in 7.x.
| Pre-release | 8.0.0 |
|---|---|
new Collection(...), new Attribute(...), new Index(...), new Relationship(...), the 18 Attribute\* subclasses |
Collection::create() and the factories; the constructors are private |
Attribute::getKey(), getType(), isArray(), getFormat() and the other getters, $attribute->type through magic __get() |
Public readonly properties ($attribute->type) |
Attribute::persistedType(), normalizeType(), tryNormalizeType() |
Attribute::storedType(), Attribute::typeFromStored() |
Attribute::isSpatialType($type) and the other static is*Type(), getNumericBounds(), setFilters() |
isSpatial(), isNumeric(), isInteger(), bounds(), withFilters() on the value object |
Attribute::linestring(), Index::fullText(), Index::index() |
Attribute::lineString(), Index::fulltext(), Index::key() |
Attribute::availableTypes(objects:, spatial:, vectors:) |
Attribute::availableTypes(Adapter\Profile $profile) |
Index::getIndexedAttributes(), setLengths(), setOrders(), orders as Utopia\Query\Schema\Order |
$index->attributes, withLengths(), withOrders(), orders as OrderDirection |
Relationship(collection: ...), getSourceCollection(), RelationType, RelationSide, ForeignKeyAction $onDelete |
createRelationship($collection, ...), RelationshipType, RelationshipSide, RelationshipDeleteAction |
Collection::getDeclaredAttributes(), getIndexes(), getName(), getDeclaredPermissions(), hasDocumentSecurity(), $metadata, isEmpty() on a missing collection |
attributes(), indexes(), name(), declaredPermissions(), documentSecurity(), Collection::create(metadata:), findCollection() |
createRelationship(Relationship $relationship) |
createRelationship(string $collection, Relationship $relationship) |
updateAttribute(..., ColumnType|string|null $type, ...), updateRelationship(..., ?ForeignKeyAction $onDelete), updateCollection(string $id, ...) |
AttributeUpdate, RelationshipUpdate, CollectionUpdate |
Database::ATTRIBUTE_FILTER_COLUMN_TYPES, Database::INTERNAL_ATTRIBUTES, static internalAttributes(), getInternalAttributes(), internalAttributeDocuments() |
The factories add their filters; $database->internalAttributes() |
Validator\Attribute, Validator\Index with positional booleans, Queries\Bounds |
AttributeDefinition, IndexDefinition with an Adapter\Profile; $profile->limits |
The six mandatory Feature\{Attributes,Collections,Databases,Documents,Indexes,Transactions} interfaces |
The abstract methods of Adapter |
Feature\SchemaAttributes, Feature\SchemaIndexes, Feature\ColumnTypes |
Capability::SchemaIntrospection and the abstract getSchemaAttributes(), getSchemaIndexes(), getColumnType() |
Feature\InternalCasting (castingBefore(), castingAfter(), castingAfterDocuments()), Feature\UTCCasting (setUTCDatetime()) |
Feature\Casting (castBefore(), castAfter() over a page, castDatetime()) |
Feature\ConnectionId (getConnectionId()), Capability::Hostname, Capability::Reconnection |
Feature\Connection (id(), hostname(), ping(), reconnect()) |
Feature\QueryBuilder::getBuilder(), getSchema(), createSchemaBuilder() |
builder(), schema() |
Feature\Spatial::decodePoint(), decodeLinestring(), decodePolygon(), Database::encodeSpatialData() |
Feature\Spatial::encode(), decode() |
Feature\Upserts::upsertDocuments(Document $collection, string $attribute, array $changes), Change::setOld(), setNew(), getOld(), getNew() |
upsertDocument(), upsertDocuments(Document $collection, array $changes, ?string $increase = null), $change->old, $change->new |
Capability::Casting |
Feature\Casting: the library casts unless the adapter implements it |
Capability::AtomicTransactions, BatchCreateAttributes, BatchOperations, BoundaryInclusive, CacheSkipOnFailure, JSONOverlaps, MultiDimensionDistance, NumericCasting, OptionalSpatial, PCRE, POSIX, QueryContains, Regex, StatisticalAggregates, BitwiseAggregates |
Removed. SQLite refuses the statistical and bitwise aggregates with Exception\Query |
Capability::Index, UniqueIndex, Fulltext, FulltextWildcard, MultipleFulltextIndexes, TrigramIndex, TTLIndexes, ObjectIndexes, CastIndexArray, IdenticalIndexes, SpatialIndexNull, SpatialIndexOrder, NestedTransactions |
IndexKey, IndexUnique, IndexFulltext, IndexFulltextWildcard, IndexFulltextMultiple, IndexTrigram, IndexTtl, IndexObject, IndexArrayCast, IndexIdentical, IndexSpatialNull, IndexSpatialOrder, TransactionNested |
Adapter::getMaxUIDLength() and the other limit getters, Database::getMaxUIDLength() |
limits(); Database::getMaxUidLength() |
SQL::getPDO() (public, deprecated) |
getDriver() |
Database::execute() |
query() for reads (a list of Document), mutate() for writes (the affected row count) |
Database::enableProfiling(), disableProfiling(), Profiler\QueryProfiler, Profiler\QueryLog |
setProfiling(bool), Utopia\Database\Profiler, Profiler\Log |
Database::setTypeRegistry(), Type\Custom, Type\TypeRegistry, an associative constructor $filters |
setFilters(), Filter\Codec, Filter\Registry, a list of Filter\Codec |
Cache\QueryCache with $cacheName and writerTimeout arguments |
Cache\Query, which uses those of the Database calling it |
Authorization::withStatus() |
skip(), or setStatus() inside any with*() scope |
Hook\Lifecycle::handle(Event $event, mixed $data) |
handle(Event\Domain $event) |
Event\Documents\Created, Updated, Deleted |
Event\Document\BatchCreated, BatchUpdated, BatchDeleted |
Event\Domain::$occurredAt |
Removed |
Cache\Invalidator as a Hook\Lifecycle |
A hook of its own kind; addHook() still takes it |
Hook\Write extending Utopia\Query\Hook\Write, its row-level after*(string $table, ...) methods, the closure-bag write context, Document::SKIP_PERMISSIONS_UPDATE |
decorateRow(array $row, Hook\RowMetadata $metadata), the document-level afterDocument*() methods and Hook\WriteContext::skipPermissions() |
new Relationships($database) |
new Relationships(), attached by addHook() |
Adapter::hasTenantHook(), hasPermissionHook() |
Removed |
Hook\PermissionFilter, PermissionJoinFilter, OuterJoinPermissionFilter |
Adapter\SQL\Hook\Permission\Filter, Join, OuterJoin |
Hook\TenantFilter, RawTenantFilter, OuterJoinTenantFilter, RawOuterJoinTenantFilter |
Adapter\SQL\Hook\Tenant\Filter, Raw, OuterJoin, RawOuterJoin |
Hook\JoinChain, OuterJoinChainFilter, AllowNullColumn, PermissionAllowNullUid |
Adapter\SQL\Hook\Join\Chain, Join\OuterChain, Column\AllowNull, Column\AllowNull |
Hook\Read, Hook\Mongo\PermissionFilter, Hook\Mongo\TenantFilter |
Hook\Mongo\Read, Hook\Mongo\Permission, Hook\Mongo\Tenant |
Traits\, Builder\PostgreSQL, State\Scope |
Trait\, Builder\Postgres, State\Frame |
Validator\Query\JoinedCollection, JoinedAttributes |
Validator\Query\Joined\Collection, Joined\Attributes |
A plain class extending Hook\PermissionFilter; Hook\TenantFilter, Hook\Mongo\PermissionFilter and Hook\Mongo\TenantFilter as base classes |
Adapter\SQL\Hook\Permission\Filter is readonly, so a subclass, such as one an adapter's newPermissionHook() or newJoinPermissionHook() override returns, is declared readonly class; Adapter\SQL\Hook\Tenant\Filter, Hook\Mongo\Permission and Hook\Mongo\Tenant are final readonly |
The protected SQL adapter methods compileAdapterFilter(), getOperatorBuilderExpression(), getOperatorUpsertExpression() and getVectorOrderRaw() returning array{expression: string, bindings: list<mixed>}; compileAdapterFilter()'s $joins as list<array{table: string, alias: string}> |
They return Adapter\SQL\Expression ($sql, $bindings), nullable where the array was; $joins is a list<Adapter\SQL\JoinAlias> ($table, $alias) |
Cache\Region with writable $ttl and $enabled; Profiler\QueryLog as a base class |
Cache\Region and Profiler\Log are final readonly: pass a new Region to Cache\Query::setRegion() |
Storage::PERMS_SUFFIX, PERM_DOCUMENT, PERM_TYPE, PERM_PERMISSION |
Storage::PERMISSIONS_TABLE_SUFFIX, PERMISSIONS_DOCUMENT, PERMISSIONS_TYPE, PERMISSIONS_PERMISSION |
Query::join($collection, $left, $right, $operator, $alias) and the other column-form joins, getJoinAlias(), Storage::joinAlias() |
Query::join($collection, $alias, [Query::on(...)]), getAlias(); every join names its alias |
Query::groupForDatabase() |
Query::groupByType(), which returns a ParsedQuery |
Document::INTERNAL_ID |
Document::SEQUENCE |
Hook\Permissions::UNCHANGED, WriteContext::skipPermissions() without arguments |
WriteContext::skipPermissions(Document $document), fed by Adapter::updateDocuments(..., $skipPermissions) |
Snapshot::$skipDuplicates, skippingDuplicates() |
Snapshot::$ignoreDuplicates, isIgnoringDuplicates() |
Mirror::onError(callable(string $action, \Throwable $error)), awaitReplications() without a timeout |
onError(callable(Mirror\Failure $failure)), awaitReplications(?int $timeout = null) |
Adapter::setDebug(), getDebug(), resetDebug() |
setMetadata(), getMetadata(), resetMetadata() |
Exception::__construct(string $message, ...) with a string code cast to 0 |
Every argument optional; a string code kept in $state |
These features do not exist in 7.x. Their rules are listed here because they differ from what a reader of the 7.x API might expect. CHANGELOG.md describes the features themselves.
Every join names its alias and takes its conditions as a list: Query::join(string $collection, string $alias, array $on), leftJoin(), rightJoin() and fullOuterJoin() take Query::on(string $left, string $right, string $operator = '=') conditions and filters, and crossJoin(string $collection, string $alias) and naturalJoin() take none. In
on() the left column belongs to the main collection or to a join declared before it, and the right one to the
joined collection. An ON
list member that is not on() or a filter (limit(), select(), an order, ...) throws
Utopia\Query\Exception\ValidationException when the query is built.
$database->find('reviews', [
Query::join('movies', 'm', [Query::on('movie', '$id')]),
Query::select(['body', 'm.name']),
]);- A joined collection is read exactly as a direct
find()on it would be. If the caller holds the collection-level permission, every row of the joined collection is visible. If not, and the collection has document security, only the rows the caller holds document-level read on are visible. If neither applies, the query throwsUtopia\Database\Exception\Authorization. Adding a join never hides main-collection rows that are readable through the collection grant, infind(),count(),sum()andgetDocument(). - A query can declare at most 8 joins (
Validator\Query\Join::MAX_PER_QUERY). More is rejected withUtopia\Database\Exception\Query(Too many joins: at most 8 are allowed) byfind(),count(),sum()andgetDocument(), also insideskipValidation(). - Join aliases must be identifiers, unique within the query regardless of case, and different from
Query::DEFAULT_ALIASand from the key of a relationship attribute of the main collection; anything else throwsUtopia\Database\Exception\Query. An alias equal to a relationship key is refused withJoin alias "<alias>" is the key of the relationship attribute "<alias>": give the join another alias, soalias.attributeandalias.*never mean both a joined column and a related document's attribute. Relationship keys are matched by exact name. - A join's ON list (
Query::join($collection, $alias, [...])) holdsQuery::on()conditions and the plain filtersequal,notEqual,greaterThan,greaterThanEqual,lessThan,lessThanEqual,between,notBetween,isNull,isNotNull,contains,containsAny,notContains,startsWith,notStartsWith,endsWithandnotEndsWith, alone or insideand()/or(). Anything else in an ON list (limit,offset, cursors, orders, selects, aggregates, joins,containsAll,search,regex,exists, vector and spatial queries) throwsUtopia\Database\Exception\Query(Unsupported join ON condition: <method>) infind(),count(),sum()andgetDocument(). - A filter on a joined column (
alias.attribute) is checked against the joined collection's attribute exactly as a filter on the main collection is checked against its own: type, size, array-ness and thecontainsrules. A vector query cannot target a joined attribute (Vector queries cannot be used on a joined attribute: <alias.attribute>). - A filter on a joined column, in the query, in a join's ON list or in
having(), is also converted as a filter on the main collection is: arraycontains*filters match elements, and datetimes (withalias.$createdAtandalias.$updatedAt) are compared in UTC. - A select of joined attributes returns what it names. The main collection's unselected attributes are left out, as
for any select; only a select of a related document's attributes (
relationship.attribute) returns the others as well, as in 7.x. - A join without a select, or with
select(['*']), returns under each join alias the joined collection's$idand attributes asalias.$idandalias.attribute, plus each joined internal attribute the read orders by (alias.$sequence,alias.$createdAt,alias.$updatedAt), so its rows can be passed back as a cursor. - A select may name
alias.*, alone or next to other selects: it returns the joined row as a direct read of the joined collection returns it, its$id,$sequence,$createdAt,$updatedAt,$permissionsand attributes asalias.$id,alias.$sequence, ... andalias.attribute. It never returns the joined$tenant: selectalias.$tenantto read it. A row an outer join left unmatched holds null for each of them. An aggregation query still rejectsalias.*as an ungrouped select, and an alias the query does not join is not found. - Joined columns named next to
*are returned with everything*returns:select(['*', 'alias.$createdAt'])returns the main document, the joined$idand attributes, andalias.$createdAt. - An order may name a joined attribute by its bare name when the main collection does not declare it and exactly one
join's collection does (
orderAsc('price')over a join whose collection declaresprice); a name the main collection declares always orders by the main collection. A bare name more than one join declares throwsUtopia\Database\Exception\Query(Attribute "<name>" is ambiguous across joins; qualify it with a join alias). A bare order pages with a cursor like its qualified form: the cursor row holds the value underalias.price. - Index the attributes your join conditions compare. A join on an unindexed attribute is accepted, but the engine has to scan the joined table to pair its rows, and on a large collection such a read can exceed the statement timeout. On MariaDB 10.11 a join whose ON columns are unindexed can be very slow: MariaDB has no hash join, and its optimizer prunes the join orders it considers, so it can pair each row with a scan of the joined table. On MySQL under shared tables, an inner or left join whose own ON no index serves reads the tenant's rows of the joined table once, matching its tenant as a range, instead of once per row the join pairs.
- On MySQL, a joined collection's permission check is kept out of the optimizer's semi-join search (
NO_SEMIJOIN) when the collection is left, right or full outer joined, and for every joined collection from five joins. Inner joins below five keep semi-joins. Right after a collection is created or bulk-loaded, until InnoDB's automatic statistics recalculation has run (seconds, with the defaultSTATS_AUTO_RECALC), a left join on a joined collection's own$idcan be slow. RunANALYZE TABLEafter a bulk load to avoid that window. - On PostgreSQL, planning a read takes about 2.6 to 3.2 times longer with each join from the fifth: on a
development machine, about 2.7 ms with five permission-checked joins, 6.9 ms with six and 20.7 ms with seven. It
stops growing at eight joins, where PostgreSQL's default
join_collapse_limitends its search for a join order. Keep a read to about five joins, or split it into several reads. - On MariaDB and MySQL, a one-to-many joined read is ordered by the joined
$idbehind the main$sequenceonly when its rows show the join (noselect(),*, or a joined attribute) or it pages with a cursor; see Paging a joined read. Without a bound, the engine sorts the whole join before applying the limit. A read bounds the sort when every join is a left join, no filter or search names a joined attribute, the order starts with main attributes (the main$sequenceor$idamong them) and it has a limit: the read first picks the main documents its page can reach (offset + limit, plus the cursor's own), including those matched by a fulltext search on main attributes, and joins only those. Inner, right and full outer one-to-many joins, filters and searches on joined attributes and orders that start with a joined attribute still sort the whole join on MariaDB and MySQL. PostgreSQL is not affected. Index the join keys, and for a large one-to-many read select main attributes only or use left joins. Database::updateDocuments()andDatabase::deleteDocuments()do not accept join queries. They throwUtopia\Database\Exception\QuerywithJoin queries are not supported for bulk updatesorJoin queries are not supported for bulk deletes.- On adapters without joins or aggregations (Memory, MongoDB, Redis),
find(),aggregate(),count()andsum()reject those queries during validation withInvalid query: Invalid query method: <method>, the message 7.x gave for a method it could not parse. - MariaDB, MySQL and SQLite run a full outer join as two queries joined by
UNION ALL. They accept one full outer join per query, and a right join after it has to join on a table joined before the full outer join or on the full outer joined table (directly or through other joins); other chains throwUtopia\Database\Exception\Query. PostgreSQL runs full outer joins natively and has no such limit. A full outer join combined with a right join under shared tables reads what a dedicated database reads, on every engine.
- A cursor over a joined read names the row the read returned, not only its main document. A joined row read is
ordered by its explicit orders, then by the main
$sequence(unless an order names the main$idor$sequence), then, for each join that can pair a row with several joined rows, by that join'salias.$idascending (unless an order names that alias's$idor$sequence). An inner or left join whose condition compares the joined$idwith=pairs at most one joined row and adds no order. Every page is ordered the same way, so paging withcursorAfter()orcursorBefore()returns each joined row exactly once. - Pass a row the same read returned as the cursor. It has to carry every value the read orders by, under the name the
read orders by (
note.score,$sequence,note.$id); a missing value throwsUtopia\Database\Exception\Order(Cursor has no value for order attribute 'note.$id'. …). A value is never taken from the main document's attribute of the same name. A read whoseselect()names attributes without*has to select every joined value it orders by, a paged join'salias.$idincluded, to be paged;alias.*selects them all. A read without a select, or with*, returns them.cursor()checks the last row of each full batch before yielding the batch, so such a read throws before the first row; a read that fits in one batch is not paged and needs no such value. - A value may be null (a row an outer join did not match, a nullable attribute). Nulls keep the engine's position: first in ascending order on MariaDB, MySQL and SQLite, last on PostgreSQL, and the cursor pages through them.
- A row a right or full outer join returned without a main document (its
$idis'') is a valid cursor for that read. A read without joins ordistinct()still refuses a cursor document without an$id(Invalid query: Invalid cursor: …). getDocument()with a join that matches several joined rows returns the one with the lowest$sequence, join by join in join order.
Aggregation queries run through Database::aggregate($collection, $queries), which returns the rows as arrays (see
Bulk writes and reads); find() refuses them.
$rows = $database->aggregate('orders', [
Query::count('*', 'orders'),
Query::sum('total', 'revenue'),
Query::groupBy(['status']),
]);
// [['status' => 'paid', 'orders' => 12, 'revenue' => 340.5], ...]- An aggregation query is one with an aggregate (
count,countDistinct,sum,avg,min,max, the statistical and the bitwise aggregates) or agroupBy(). AgroupBy()without an aggregate counts. A select in an aggregation query may name only the attributes the query groups by; any other select throwsUtopia\Database\Exception\Query(Invalid query: Cannot select "<attribute>": an aggregation query can only select the attributes it groups by). Group by the attribute or leave it out of the select.*and relationship wildcards (key.*,parent.child.*) are accepted and ignored, so a listing that always adds them keeps working. A join alias'salias.*is rejected like any ungrouped select. A join alias cannot equal a relationship key of the main collection (see Joins), sokey.*is always the relationship's wildcard. The rule applies toaggregate(),count()andsum(), which validate their query set the same way. - An order in an aggregation query may name only an aggregate alias or an attribute the query groups by, and
orderRandom()is accepted; any other order throwsUtopia\Database\Exception\Query(Invalid query: Cannot order by "<attribute>": an aggregation query can only order by its groups and aggregates). A grouped attribute that only a joined collection declares matches its bare name and its aliased name alike. - A
Utopia\Database\Validator\Query\SelectorValidator\Query\Orderbuilt directly applies its rule only when it is handed the query set's aggregates and groups (setAggregations(),setGroupBy()), asUtopia\Database\Validator\Queries\Basedoes. having()conditions follow the filter rules. Each condition compares an aggregate alias of the same query, or an attribute passed togroupBy(). Compare aliases only at the top level ofhaving().search()insidehaving()needs a fulltext index, and value lists are capped bysetMaxQueryValues().sum,avg,stddev*andvariance/var*need a numeric, non-array attribute.bitAnd,bitOrandbitXorneed an integer attribute. Onlycountaccepts*.minandmaxneed an attribute whose values every engine can order: not an array, object, boolean, spatial or vector attribute (PostgreSQL has neither function for its BOOLEAN, JSONB, GEOMETRY and VECTOR columns), on the main collection and under a join alias. Otherwise they throwUtopia\Database\Exception\Query(Aggregate <min|max> requires an attribute whose values are ordered, not an array, object, boolean, spatial or vector one: <attribute>).countandcountDistinctaccept every attribute.- Aggregates and
groupBy()refuse a relationship attribute on a side that holds no column (the parent side of a one-to-many, the child side of a many-to-one and of a one-way one-to-one, either side of a many-to-many), as filters do:Cannot aggregate virtual relationship attribute: <key>,Cannot group by virtual relationship attribute: <key>. - SQLite has no statistical (
stddev,stddevPop,stddevSamp,variance,varPop,varSamp) or bitwise (bitAnd,bitOr,bitXor) aggregate functions, andaggregate()throwsUtopia\Database\Exception\Queryfor them there. - An aggregate alias is an identifier (letters, digits and
_, not starting with a digit) of at most 63 characters (Validator\Query\Aggregate::MAX_ALIAS_LENGTH), and it cannot repeat another aggregate's alias or the name a grouped attribute is returned under. - Over an empty result,
countandcountDistinctreturn0and every other aggregate returnsnull, on every adapter.Database::sum()still returns0for no rows, as in 7.x. - With joins, a bare attribute in an aggregate function or
groupBy()refers to the main collection's attribute when the main collection declares it, else to the attribute of the one joined collection that declares it. A name no collection declares, or that more than one join declares, throwsUtopia\Database\Exception\Query; qualify it with the join alias (alias.attribute). Database::sum($collection, $attribute, $queries)resolves a bare$attributethe same way: the main collection's attribute when it declares one, else the attribute of the one join that declares it (sum('orders', 'price', [$join])). A name several joins declare throws the ambiguity error above.- Each group is returned under the column name the engine gives it (
groupBy(['note.name'])returnsname), unless another group of the query is returned under the same name: then the joined group is returned under its qualified name.groupBy(['name', 'note.name'])returnsname(the main collection's) andnote.name; two joined groups of one name (groupBy(['a.code', 'b.code'])) returna.codeandb.code. - Over an empty result an unaliased
bitAnd,bitOrorbitXorreturnsnullas an aliased one does (MariaDB and MySQL answer them with every bit set or0). On PostgreSQL every unaliasedbitAndis namedbit_and, so give them aliases to read more than one. - An aggregation query is not ordered by vector distance: a vector query only filters it, keeping the rows that have
a vector, and a fulltext
search()only filters it, as it filters every read. - On MariaDB under shared tables, an aggregation query or a
distinct()read can examine many more rows than on dedicated tables: the engine reads the tenant's rows in the order of the index it groups by and checks permissions row by row, instead of checking permissions first. With 50,000 documents and one tenant it takes about 5 times as long as on dedicated tables (43 to 50 ms against 9 to 10 ms on a development machine). The more tenants a table holds, the fewer rows each tenant's range covers. MySQL and PostgreSQL read the same way in both modes.
- A
distinct()read is ordered only by its explicit orders. Next to a vector query it is not ordered by distance, and its rows carry no$distance: the vector query only filters it, keeping the rows that have a vector. To order by distance, read rows withoutdistinct(). Asearch()filters a distinct read, as it filters every read. A distinct row has no$id, so a cursor on a distinct read pages by its order values alone: pass a row the read returned, and give the read aselect()of named attributes and an order on each of them. Otherwise the read throwsUtopia\Database\Exception\Query(A cursor on a distinct() read pages along its orders, …). - Vector distance orders row reads only, not aggregation queries or distinct reads. A distinct row stands for every
row with its selected values, so it has no single distance, and PostgreSQL and MySQL order a
SELECT DISTINCTonly by selected columns. Without an explicit order, a distinct read's rows come back in engine order. - On PostgreSQL and MySQL,
distinct()with an order on an attribute the select leaves out throwsUtopia\Database\Exception\Query(A distinct() query can only be ordered by a selected attribute on this database). Select the order attribute as well. On MariaDB, MySQL and SQLite the same applies to adistinct()query over a full outer join.
A fulltext search() only filters, as in 7.x. A search read is ordered by its explicit orders and then by
$sequence, and a cursor pages along them. Results are not ranked by relevance and carry no _relevance
attribute.
Database::from($collection) returns a utopia-php/query builder over the collection's table.
Database::query($statement) runs a read as written and returns a list of Document, and
Database::mutate($statement) runs a write and returns the affected row count. They are available on the SQL
adapters only
(Feature\QueryBuilder); elsewhere they throw Utopia\Database\Exception. They are an escape hatch for statements
the document API cannot express: they check no permissions, bypass the document and query caches (call
purgeCachedDocument() for documents you change: it also invalidates the collection's cached find() results),
write no permission rows, skip validation and run no hooks or events (a Mirror does not replicate them). Both throw
Utopia\Database\Exception\Authorization unless authorization is disabled: build and run them inside
$database->getAuthorization()->skip(fn () => ...).
For SQL you write yourself, pass query() a Utopia\Query\Builder\Statement, new Statement($sql, $bindings): it
runs the statement as written, checks no permissions, applies no tenant scope (under shared tables it reads every tenant's rows unless the
SQL limits them) and runs no hooks or events. It throws Utopia\Database\Exception\Authorization unless
authorization is disabled: run it inside $database->getAuthorization()->skip(fn () => ...).
Skipping authorization does not skip tenancy for from(). Under shared tables a read stays within the tenant selected when
from() handed the builder out: the main table and every table joined through the builder's join methods. An
update() or delete() is kept to the tenant on its main table; the builder's join methods do not apply to them.
To read another tenant, select it with setTenant() or withTenant(); there is no other opt-out. A right or full
outer join needs the main table named as from() names it, and a statement without a main table is refused
(Utopia\Database\Exception\Query). Not scoped for you: SQL you write yourself, and builders not obtained from
from() (subqueries, unions, lateral joins).
from() builds on the adapter's builder() (Feature\QueryBuilder), which takes no collection:
$adapter->builder()->from($collection, $alias) is the builder Database::from($collection, $alias) hands out,
without the Database's executor. Its from() takes a collection id, and the alias names the main table, which is
then the name the tenant condition and a right or full outer join pair with. Its joins take collection ids too,
whether they were added before or after from() and through the join methods, filter() or queries().
It reads that one collection under that alias. from() of it again (after reset(), say) keeps its tenant scope,
and everything that would leave the scope behind throws Utopia\Database\Exception\Query; start another builder
instead:
from()of another collection, or of the same collection under another aliasfromTable()andinto()scope()- a dialect's multi-table write, whose second table the tenant scope does not reach:
updateJoin()anddeleteJoin()on MySQL and MariaDB,updateFrom()anddeleteUsing()on PostgreSQL
A builder that names no collection is kept to no tenant: fromNone(), fromTable(), which reads a table by the
name it is stored under (as do its joins), and into(). An insert goes through such a builder, run with
mutate(), and writes exactly the columns you give it, $tenant included:
$database->mutate($adapter->builder()->into($table)->set([...])->insert()), with $table the name the table is
stored under.
Query::exists([...]) and Query::notExists(...) run on the SQL adapters and take attribute names as their values.
On adapters with defined attributes each name has to be an attribute of the collection that holds a column, or an
alias.attribute of a join; an internal column name (_permissions), an unknown attribute, a relationship side
without a column or a related document's attribute throws Utopia\Database\Exception\Query. Schemaless adapters
accept any name.
setQueryCache(new Cache\Query($cache)) caches find() results per hostname, database, namespace, tenant and
collection. Writes through the Database invalidate only the scope they write in. purgeCachedDocument() and
purgeCachedQueries($collection) invalidate it as well; call one of them after changing data behind the library's
back.
- Use a cache adapter with generations (
Utopia\Cache\Feature\Leasable: Redis and Redis\Multiplexing, and Pool, Sharding and CircuitBreaker when the adapters they wrap have them). With any other adapter a collection's cache stays off for one region TTL after each write, because concurrent writers cannot be told apart. - Collection listings (
find('_metadata'),listCollections()) are never cached. - Under tenant-per-document, a write invalidates the scope of each written document's tenant.
- Query profiling.
Database::setProfiling(true)attaches aUtopia\Database\Profilerthat records the SQL statements the adapter runs. The profiler keeps the newestProfiler::DEFAULT_CAPACITY(1000) entries; older entries are dropped as new ones arrive. Change the bound with$database->getProfiler()?->setCapacity($entries)(at least 1).getQueryCount()andgetTotalTime()cover every statement logged since the lastreset(), including dropped ones, so they can exceed whatgetLogs()returns.setProfiling(false)stops recording and detaches the profiler from the adapter; what was captured stays readable throughgetProfiler()->getLogs()untilreset(). BehindAdapter\Poola connection carries the handle's profiler only while it is checked out. APoolsubclass that checks connections out itself (with$this->pool->use(...)) should call$this->releaseBorrowed($adapter)before giving the connection back. EachUtopia\Database\Profiler\Logcarries the statement's bound values (bindings), the collection it reads (collection, for the statementsgetDocument(),find(),count()andsum()run) and the operation that ran it (operation, theUtopia\Database\Eventvalue, such asdocument_find). Statements that bind by hand (query(),mutate(), schema changes) log no bindings, and writes log no collection.Profiler\Loghas noexplainPlan. - Capability questions behind a
Pool.supports(),capabilities()andhasFeature()ask a connection the first time and are then answered without one, for every handle built over the sameUtopia\Pools\Pool.supports(Capability::DefinedAttributes)is the exception: it reports the schema mode of the connection that answers, so it always asks one.Database::setLocks()reaches every borrowed connection. - Transactions behind a
Pool.withTransaction()pins one connection for the coroutine that calls it and the coroutines it starts. Other coroutines sharing the handle borrow connections of their own and run outside the transaction; in 7.x their statements ran on the pinned connection, inside the transaction, and their ownwithTransaction()became a savepoint in it. A coroutine started inside the transaction shares the pinned connection, so it must not run a statement while its parent runs one. Every call on the pinned connection runs under the calling coroutine's tenant. The protectedPool::$pinnedAdapterproperty is removed: a subclass reads the pinned connection throughpin(), and can override it. - Read/write splitting (
Adapter\ReadWritePool). Reads go to the read pool and writes to the write pool. After a write returns, or awithTransaction()block finishes, reads stay on the write pool for the sticky window (setStickyDuration(), default 5000 ms;setSticky(false)turns it off), so a caller reads its own writes.getDocument(..., forUpdate: true)andquery()always use the write pool, since a locking read must run on the primary and raw SQL may write; both also open the sticky window. So do the reads that decide a write: the batchupdateDocuments()anddeleteDocuments()select, and the documentupsertDocuments()compares against. Calls that touch no data (capability checks such assupports(),capabilities()andhasFeature(),limits(), value casting, and configuration such assetSchemaless()) are answered wherever a read would be and never open the sticky window.hostname()always names the write pool's host, because it namespaces document and query cache keys, and is looked up once per handle.getDriver()counts as a write: code that runs statements through the raw driver gets a write-pool connection.
- A transaction begun on the adapter directly (
$database->getAdapter()->startTransaction()) is not seen by the cache invalidation or thedocument_purgequeue thatwithTransaction()keeps. Each write inside it invalidates the caches and firesdocument_purgewhen that write returns, inside the adapter transaction and before it commits, and a rollback does not withdraw them. Reads inside it are not served from the document cache. Group writes withwithTransaction()instead.