diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 485040712f..56ef0ee482 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -1,6 +1,6 @@
name: "CodeQL"
-on: [ pull_request ]
+on: [ pull_request, workflow_dispatch ]
jobs:
lint:
name: CodeQL
@@ -13,8 +13,12 @@ jobs:
fetch-depth: 2
- run: git checkout HEAD^2
+ if: github.event_name == 'pull_request'
- name: Run CodeQL
run: |
- docker run --rm -v $PWD:/app -w /app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \
- "composer install --profile --ignore-platform-reqs && composer check"
\ No newline at end of file
+ docker run --rm -v $PWD:/app -w /app php:8.4-cli-alpine sh -c \
+ "php -r \"copy('https://getcomposer.org/installer', '/tmp/composer-setup.php');\" && \
+ php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer && \
+ composer install --profile --ignore-platform-reqs && \
+ composer check"
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
index ca49ca5c60..599a64dc9f 100644
--- a/.github/workflows/linter.yml
+++ b/.github/workflows/linter.yml
@@ -1,6 +1,6 @@
name: "Linter"
-on: [ pull_request ]
+on: [ pull_request, workflow_dispatch ]
jobs:
lint:
name: Linter
@@ -13,8 +13,10 @@ jobs:
fetch-depth: 2
- run: git checkout HEAD^2
+ if: github.event_name == 'pull_request'
- name: Run Linter
run: |
docker run --rm -v $PWD:/app -w /app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \
- "composer install --profile --ignore-platform-reqs && composer lint"
+ "composer install --profile --ignore-platform-reqs && \
+ composer lint"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 318304d9d3..283f831c0b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -6,9 +6,9 @@ concurrency:
env:
IMAGE: databases-dev
- CACHE_KEY: databases-dev-${{ github.event.pull_request.head.sha }}
+ CACHE_KEY: databases-dev-${{ github.event.pull_request.head.sha || github.sha }}
-on: [pull_request]
+on: [pull_request, workflow_dispatch]
jobs:
setup:
@@ -25,6 +25,7 @@ jobs:
uses: docker/build-push-action@1104d471370f9806843c095c1db02b5a90c5f8b6 # v3.3.1
with:
context: .
+ file: Dockerfile
push: false
tags: ${{ env.IMAGE }}
load: true
@@ -60,34 +61,48 @@ jobs:
docker compose up -d --wait
- name: Run Unit Tests
- run: docker compose exec tests vendor/bin/phpunit /usr/src/code/tests/unit
+ run: docker compose exec -e XDEBUG_MODE=off tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4 /usr/src/code/tests/unit
adapter_test:
- name: Adapter Tests
+ name: "Adapter Tests (${{ matrix.adapter }})"
runs-on: ubuntu-latest
needs: setup
strategy:
fail-fast: false
matrix:
- adapter:
- [
- MongoDB,
- MariaDB,
- MySQL,
- Postgres,
- SQLite,
- Memory,
- Mirror,
- Pool,
- Redis,
- SharedTables/MongoDB,
- SharedTables/MariaDB,
- SharedTables/MySQL,
- SharedTables/Postgres,
- SharedTables/SQLite,
- SharedTables/Redis,
- Schemaless/MongoDB,
- ]
+ include:
+ - adapter: MongoDB
+ profiles: "--profile mongo"
+ - adapter: MariaDB
+ profiles: "--profile mariadb"
+ - adapter: MySQL
+ profiles: "--profile mysql"
+ - adapter: Postgres
+ profiles: "--profile postgres"
+ - adapter: SQLite
+ profiles: ""
+ - adapter: Memory
+ profiles: ""
+ - adapter: Mirror
+ profiles: "--profile mariadb --profile mariadb-mirror --profile redis-mirror"
+ - adapter: Pool
+ profiles: "--profile mysql"
+ - adapter: Redis
+ profiles: ""
+ - adapter: SharedTables/MongoDB
+ profiles: "--profile mongo"
+ - adapter: SharedTables/MariaDB
+ profiles: "--profile mariadb"
+ - adapter: SharedTables/MySQL
+ profiles: "--profile mysql"
+ - adapter: SharedTables/Postgres
+ profiles: "--profile postgres"
+ - adapter: SharedTables/SQLite
+ profiles: ""
+ - adapter: SharedTables/Redis
+ profiles: ""
+ - adapter: Schemaless/MongoDB
+ profiles: "--profile mongo"
steps:
- name: checkout
@@ -103,7 +118,7 @@ jobs:
- name: Load and Start Services
run: |
docker load --input /tmp/${{ env.IMAGE }}.tar
- docker compose up -d --wait
+ docker compose ${{ matrix.profiles }} up -d --wait
- name: Run Tests
- run: docker compose exec -T tests vendor/bin/phpunit /usr/src/code/tests/e2e/Adapter/${{matrix.adapter}}Test.php --debug
+ run: docker compose exec -T -e XDEBUG_MODE=off tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4 /usr/src/code/tests/e2e/Adapter/${{matrix.adapter}}Test.php
diff --git a/.gitignore b/.gitignore
index 46daf3d316..1d4d5f1eeb 100755
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ Makefile
.envrc
.vscode
tmp
+*.sql
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000..0a7d213132
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,118 @@
+# Utopia Database
+
+PHP database abstraction library with a unified API across MariaDB 10.5, MySQL 8.0, PostgreSQL 13+, SQLite 3.38+, and MongoDB.
+
+## Commands
+
+| Command | Purpose |
+|---------|---------|
+| `composer build` | Build Docker containers |
+| `composer start` | Start all database containers in background |
+| `composer test` | Run tests in Docker (ParaTest, 4 parallel processes) |
+| `composer lint` | Check formatting (Pint, PSR-12) |
+| `composer format` | Auto-format code |
+| `composer check` | Static analysis (PHPStan, max level, 2GB) |
+| `composer coverage` | Check test coverage (90% minimum required) |
+
+Run a single test:
+```bash
+docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/e2e/Adapter/MariaDBTest.php
+docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests/unit/Validator/SomeTest.php
+```
+
+## Stack
+
+- PHP 8.4+, Docker Compose for test databases
+- ParaTest (parallel PHPUnit), Pint (PSR-12), PHPStan (max level)
+- Test databases: MariaDB 10.11, MySQL 8.0.43, PostgreSQL 16, SQLite, MongoDB 8.0.14
+- Redis 8.2.1 for caching tests
+
+## Project layout
+
+- **src/Database/** -- core library (PSR-4 namespace `Utopia\Database\`)
+ - `Database.php` -- main API class (uses trait composition for organization)
+ - `Adapter.php` -- base adapter class all engines extend
+ - `Adapter/` -- engine implementations: MariaDB, MySQL, Postgres, SQLite, Mongo, Pool, ReadWritePool
+ - `Adapter/SQL.php` -- shared SQL adapter base (MariaDB, MySQL, Postgres, SQLite extend this)
+ - `Adapter/Feature/` -- capability interfaces for adapter features
+ - `Document.php` -- JSON document model (extends ArrayObject)
+ - `Mirror.php` -- database mirroring/replication
+ - `Query.php` -- query builder extension
+ - `Attribute.php` -- attribute type definitions
+ - `Index.php` -- index management
+ - `Relationship.php` -- relationship definitions
+ - `Traits/` -- Database.php composition: Async, Attributes, Collections, Databases, Documents, Entities, Indexes, Relationships, Transactions
+ - `Hook/` -- event hooks and interceptors: Lifecycle, Permissions, Relationships, TenantFilter, Transform, Read, Write, WriteContext, Interceptor, Decorator, PermissionFilter, Mongo/PermissionFilter, Mongo/TenantFilter, Tenancy
+ - `Event/` -- Domain, DispatcherHook, plus Collection/{Created,Deleted} and Document/{Created,Deleted,Updated}
+ - `ORM/` -- EntityManager, EntityMapper, EntityMetadata, EntityState, IdentityMap, MetadataFactory, UnitOfWork, ColumnMapping, EmbeddableMapping, RelationshipMapping, plus `Mapping/` (Entity, Column, Id, HasMany, HasOne, BelongsTo, Embedded, Permissions, Tenant, Pre/{Persist,Remove,Update}, Post/{Persist,Remove,Update}, etc.)
+ - `Schema/` -- Introspector, Diff, Change, ChangeType, DiffResult
+ - `Validator/` -- input validators (19 top-level + subdirectories)
+ - `Helpers/` -- ID, Permission, Role utilities
+ - `Exception/` -- 18 exception types (Authorization, Duplicate, Limit, Query, Timeout, etc.)
+
+- **tests/unit/** -- unit tests for validators, helpers, etc.
+- **tests/e2e/Adapter/** -- E2E tests against real databases
+ - `Base.php` -- abstract test class all adapter tests extend
+ - `Scopes/` -- test trait mixins (DocumentTests, AttributeTests, CollectionTests, PermissionTests, RelationshipTests, SpatialTests, VectorTests, etc.)
+ - Each adapter test (MariaDBTest, PostgresTest, etc.) extends Base and gets all scope traits
+
+## Key patterns
+
+**Multi-adapter:** Single `Database` class with engine-specific `Adapter` implementations. SQL adapters share `SQL.php` base; MongoDB has its own.
+
+**Document model:** Documents are ArrayObject subclasses with reserved attributes: `$id`, `$sequence`, `$createdAt`, `$updatedAt`, `$collection`, `$permissions`.
+
+**Hook system:** Pluggable hooks for permissions, relationships, tenancy filtering, and lifecycle events. Hooks registered on the Database instance.
+
+**Custom document types:**
+```php
+$database->setDocumentType('users', User::class);
+$user = $database->getDocument('users', 'id123'); // Returns User instance
+```
+
+**Trait composition:** `Database.php` splits its API across 9 traits in `Traits/` for organization. Each trait groups related operations (documents, attributes, indexes, entities, etc.).
+
+**Connection pooling:** `Pool` adapter wraps multiple connections. `ReadWritePool` distributes reads and writes to separate pools.
+
+**Query builder:** Integrates with `utopia-php/query`. Queries grouped by type: filters, selections, aggregations, ordering, pagination.
+
+## Testing patterns
+
+- E2E tests extend `Base.php` which provides setUp/tearDown for real database connections
+- Test functionality split into trait mixins in `Scopes/` -- each adapter test includes all relevant traits
+- Unit tests in `tests/unit/` for validators and helpers
+- Tests check for `ext-swoole` and skip if missing
+
+## Docker services
+
+```bash
+composer build && composer start # Start all databases
+```
+
+Services (activated via Docker Compose profiles):
+- `mariadb` (port 3306), `mysql` (port 3307), `postgres` (port 5432), `mongo` (port 27017)
+- `redis` (port 6379) for caching
+- Mirror variants for replication tests
+- `adminer` (port 8700, debug profile) for database UI
+
+## Load testing
+
+```bash
+bin/load --adapter=mariadb # Populate test data
+bin/index --adapter=mariadb # Create indexes
+bin/query --adapter=mariadb # Run queries
+bin/compare # Visualize at localhost:8708
+```
+
+## Conventions
+
+- PSR-12 via Pint, PSR-4 autoloading
+- One class per file, filename matches class name
+- Full type hints on all parameters and returns, readonly properties for immutable data
+- Imports: alphabetical, single per statement, grouped by const/class/function
+- Constants: UPPER_SNAKE_CASE
+- Methods: camelCase with verb prefixes (get*, set*, create*, update*, delete*)
+
+## Cross-repo context
+
+Changes to the Query builder or Adapter interface may break appwrite. Run `composer test` in both repos after adapter changes.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..43c994c2d3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/Dockerfile b/Dockerfile
index d43c2a167d..15db406a18 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -115,8 +115,6 @@ RUN EXT_DIR=$(php-config --extension-dir) \
RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini
RUN echo extension=swoole.so >> /usr/local/etc/php/conf.d/swoole.ini
RUN echo extension=pcov.so >> /usr/local/etc/php/conf.d/pcov.ini
-RUN echo extension=xdebug.so >> /usr/local/etc/php/conf.d/xdebug.ini
-
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini
@@ -131,6 +129,6 @@ COPY ./dev /usr/src/code/dev
RUN if [ "$DEBUG" = "true" ]; then cp /usr/src/code/dev/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini; fi
RUN if [ "$DEBUG" = "true" ]; then mkdir -p /tmp/xdebug; fi
RUN if [ "$DEBUG" = "false" ]; then rm -rf /usr/src/code/dev; fi
-RUN if [ "$DEBUG" = "false" ]; then rm -f $(php-config --extension-dir)/xdebug.so; fi
+RUN if [ "$DEBUG" = "false" ]; then rm -f /usr/local/etc/php/conf.d/xdebug.ini; fi
CMD [ "tail", "-f", "/dev/null" ]
diff --git a/README.md b/README.md
index 309966b1d3..fdee5b4d5d 100644
--- a/README.md
+++ b/README.md
@@ -355,58 +355,23 @@ $database->getKeywords();
### Collection Methods
```php
-// Creates two new collection named '$namespace_$collectionName' with attribute names '_id', '_uid', '_createdAt', '_updatedAt', '_permissions'
+// Creates two new collection named '$namespace_$collectionName' with attribute names '_id', '_uid', '_createdAt', '_updatedAt', '_permissions'
// The second collection is named '$namespace_$collectionName_perms' with attribute names '_id', '_type', '_permission', '_document'
-$database->createCollection(
- id: 'users'
-);
-
-// Create collection with attributes and indexes
-$attributes = [
- new Document([
- '$id' => ID::unique(),
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any())
- ],
- 'name' => 'Jhon',
- 'age' => 20
- ]),
- new Document([
- '$id' => ID::unique(),
- '$permissions' => [
- Permission::read(Role::any()),
- Permission::update(Role::any()),
- Permission::delete(Role::any())
- ],
- 'name' => 'Doe',
- 'age' => 34
- ]),
-]
-
-$indexes = [
- new Document([
- '$id' => ID::unique(),
- 'type' => Database::INDEX_KEY,
- 'attributes' => ['name'],
- 'lengths' => [256],
- 'orders' => ['ASC'],
- ]),
- new Document([
- '$id' => ID::unique(),
- 'type' => Database::INDEX_KEY,
- 'attributes' => ['name', 'age'],
- 'lengths' => [128, 128],
- 'orders' => ['ASC'],
- ])
-];
+$database->createCollection(new Collection(
+ id: 'users',
+));
-$database->createCollection(
- id: 'users',
- attributes: $attributes,
- indexes: $indexes
-);
+$database->createCollection(new Collection(
+ id: 'users',
+ attributes: [
+ Attribute::string(key: 'name', size: 256),
+ Attribute::integer(key: 'age'),
+ ],
+ indexes: [
+ Index::key(key: 'idx_name', attributes: ['name'], lengths: [256], orders: ['ASC']),
+ Index::key(key: 'idx_name_age', attributes: ['name', 'age'], lengths: [128, 128], orders: ['ASC']),
+ ],
+));
// Update Collection Permissions
$database->updateCollection(
@@ -633,22 +598,22 @@ $database->createRelationship(
);
// Relationship onDelete types
-Database::RELATION_MUTATE_CASCADE,
-Database::RELATION_MUTATE_SET_NULL,
-Database::RELATION_MUTATE_RESTRICT,
+ForeignKeyAction::Cascade->value,
+ForeignKeyAction::SetNull->value,
+ForeignKeyAction::Restrict->value,
// Update the relationship with the default reference attributes
$database->updateRelationship(
collection: 'movies',
id: 'users',
- onDelete: Database::RELATION_MUTATE_CASCADE
+ onDelete: ForeignKeyAction::Cascade->value
);
// Update the relationship with custom reference attributes
$database->updateRelationship(
collection: 'movies',
id: 'users',
- onDelete: Database::RELATION_MUTATE_CASCADE,
+ onDelete: ForeignKeyAction::Cascade->value,
newKey: 'movies_id',
newTwoWayKey: 'users_id',
twoWay: true
@@ -755,25 +720,25 @@ $database->decreaseDocumentAttribute(
// Update the value of an attribute in a document
// Set types
-Document::SET_TYPE_ASSIGN, // Assign the new value directly
-Document::SET_TYPE_APPEND, // Append the new value to end of the array
-Document::SET_TYPE_PREPEND // Prepend the new value to start of the array
+SetType::Assign, // Assign the new value directly
+SetType::Append, // Append the new value to end of the array
+SetType::Prepend // Prepend the new value to start of the array
Note: Using append/prepend with an attribute which is not an array, it will be set to an array containing the new value.
$document->setAttribute(key: 'name', 'Chris Smoove')
- ->setAttribute(key: 'age', 33, Document::SET_TYPE_ASSIGN);
+ ->setAttribute(key: 'age', 33, SetType::Assign);
$database->updateDocument(
- collection: 'users',
- id: $document->getId(),
+ collection: 'users',
+ id: $document->getId(),
document: $document
-);
+);
// Update the permissions of a document
-$document->setAttribute('$permissions', Permission::read(Role::any()), Document::SET_TYPE_APPEND)
- ->setAttribute('$permissions', Permission::create(Role::any()), Document::SET_TYPE_APPEND)
- ->setAttribute('$permissions', Permission::update(Role::any()), Document::SET_TYPE_APPEND)
- ->setAttribute('$permissions', Permission::delete(Role::any()), Document::SET_TYPE_APPEND)
+$document->setAttribute('$permissions', Permission::read(Role::any()), SetType::Append)
+ ->setAttribute('$permissions', Permission::create(Role::any()), SetType::Append)
+ ->setAttribute('$permissions', Permission::update(Role::any()), SetType::Append)
+ ->setAttribute('$permissions', Permission::delete(Role::any()), SetType::Append)
$database->updateDocument(
collection: 'users',
diff --git a/bin/cli.php b/bin/cli.php
index 77f462eabe..7054c3e641 100644
--- a/bin/cli.php
+++ b/bin/cli.php
@@ -2,13 +2,68 @@
require_once '/usr/src/code/vendor/autoload.php';
+use Utopia\Cache\Adapter\None as NoCache;
+use Utopia\Cache\Cache;
use Utopia\CLI\CLI;
use Utopia\Console;
+use Utopia\Database\Adapter\MariaDB;
+use Utopia\Database\Adapter\MySQL;
+use Utopia\Database\Adapter\Postgres;
+use Utopia\Database\Database;
+use Utopia\Database\PDO;
+use Utopia\DI\Dependency;
ini_set('memory_limit', '-1');
$cli = new CLI();
+$database = new Dependency();
+$database
+ ->setName('database')
+ ->setCallback(static fn (): callable => static function (string $adapter, string $name, string $namespace, bool $sharedTables): Database {
+ $adapters = [
+ 'mariadb' => [
+ 'dsn' => 'mysql:host=mariadb;port=3306;charset=utf8mb4',
+ 'user' => 'root',
+ 'password' => 'password',
+ 'class' => MariaDB::class,
+ ],
+ 'mysql' => [
+ 'dsn' => 'mysql:host=mysql;port=3307;charset=utf8mb4',
+ 'user' => 'root',
+ 'password' => 'password',
+ 'class' => MySQL::class,
+ ],
+ 'postgres' => [
+ 'dsn' => 'pgsql:host=postgres;port=5432',
+ 'user' => 'postgres',
+ 'password' => 'password',
+ 'class' => Postgres::class,
+ ],
+ ];
+
+ $config = $adapters[$adapter] ?? throw new RuntimeException("Adapter '{$adapter}' not supported");
+ $class = $config['class'];
+ $pdo = new PDO(
+ $config['dsn'],
+ $config['user'],
+ $config['password'],
+ $class::getPDOAttributes(),
+ );
+
+ $database = (new Database(new $class($pdo), new Cache(new NoCache())))
+ ->setDatabase($name)
+ ->setNamespace($namespace)
+ ->setSharedTables($sharedTables);
+
+ if (! $database->exists()) {
+ $database->create();
+ }
+
+ return $database;
+ });
+$cli->setResource($database);
+
include 'tasks/load.php';
include 'tasks/index.php';
include 'tasks/query.php';
diff --git a/bin/tasks/index.php b/bin/tasks/index.php
index 256f23ce10..d1cd493921 100644
--- a/bin/tasks/index.php
+++ b/bin/tasks/index.php
@@ -13,7 +13,9 @@
use Utopia\Database\Adapter\MySQL;
use Utopia\Database\Adapter\Postgres;
use Utopia\Database\Database;
+use Utopia\Database\Index;
use Utopia\Database\PDO;
+use Utopia\Query\Schema\Order;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
@@ -61,8 +63,9 @@
],
];
- if (!isset($dbAdapters[$adapter])) {
+ if (! isset($dbAdapters[$adapter])) {
Console::error("Adapter '{$adapter}' not supported");
+
return;
}
@@ -82,31 +85,31 @@
Console::info("Creating key index 'createdGenre' on 'articles' for created > '2010-01-01 05:00:00' and genre = 'travel'");
$start = microtime(true);
- $database->createIndex('articles', 'createdGenre', Database::INDEX_KEY, ['created', 'genre'], [], [Database::ORDER_DESC, Database::ORDER_DESC]);
+ $database->createIndex('articles', Index::key(key: 'createdGenre', attributes: ['created', 'genre'], orders: [Order::Desc, Order::Desc]));
$time = microtime(true) - $start;
Console::success("Index 'createdGenre' created in {$time} seconds");
Console::info("Creating key index 'genre' on 'articles' for genres: fashion, finance, sports");
$start = microtime(true);
- $database->createIndex('articles', 'genre', Database::INDEX_KEY, ['genre'], [], [Database::ORDER_ASC]);
+ $database->createIndex('articles', Index::key(key: 'genre', attributes: ['genre'], orders: [Order::Asc]));
$time = microtime(true) - $start;
Console::success("Index 'genre' created in {$time} seconds");
Console::info("Creating key index 'views' on 'articles' for views > 100000");
$start = microtime(true);
- $database->createIndex('articles', 'views', Database::INDEX_KEY, ['views'], [], [Database::ORDER_DESC]);
+ $database->createIndex('articles', Index::key(key: 'views', attributes: ['views'], orders: [Order::Desc]));
$time = microtime(true) - $start;
Console::success("Index 'views' created in {$time} seconds");
Console::info("Creating fulltext index 'fulltextsearch' on 'articles' for search term 'Alice'");
$start = microtime(true);
- $database->createIndex('articles', 'fulltextsearch', Database::INDEX_FULLTEXT, ['text']);
+ $database->createIndex('articles', Index::fullText(key: 'fulltextsearch', attributes: ['text']));
$time = microtime(true) - $start;
Console::success("Index 'fulltextsearch' created in {$time} seconds");
Console::info("Creating key index 'tags' on 'articles' for tags containing 'tag1'");
$start = microtime(true);
- $database->createIndex('articles', 'tags', Database::INDEX_KEY, ['tags']);
+ $database->createIndex('articles', Index::key(key: 'tags', attributes: ['tags']));
$time = microtime(true) - $start;
Console::success("Index 'tags' created in {$time} seconds");
});
diff --git a/bin/tasks/load.php b/bin/tasks/load.php
index 17206de1f1..6ede25950b 100644
--- a/bin/tasks/load.php
+++ b/bin/tasks/load.php
@@ -10,11 +10,14 @@
use Utopia\Database\Adapter\MariaDB;
use Utopia\Database\Adapter\MySQL;
use Utopia\Database\Adapter\Postgres;
+use Utopia\Database\Attribute;
+use Utopia\Database\Collection;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
+use Utopia\Database\Index;
use Utopia\Database\PDO;
use Utopia\Validator\Boolean;
use Utopia\Validator\Integer;
@@ -25,7 +28,6 @@
$genresPool = ['fashion', 'food', 'travel', 'music', 'lifestyle', 'fitness', 'diy', 'sports', 'finance'];
$tagsPool = ['short', 'quick', 'easy', 'medium', 'hard'];
-
/**
* @Example
* docker compose exec tests bin/load --adapter=mariadb --limit=1000
@@ -35,11 +37,10 @@
->desc('Load database with mock data for testing')
->param('adapter', '', new Text(0), 'Database adapter')
->param('limit', 0, new Integer(true), 'Total number of records to add to database')
- ->param('name', 'myapp_' . uniqid(), new Text(0), 'Name of created database.', true)
+ ->param('name', 'myapp_'.uniqid(), new Text(0), 'Name of created database.', true)
->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true)
->action(function (string $adapter, int $limit, string $name, bool $sharedTables) {
-
$createSchema = function (Database $database): void {
if ($database->exists($database->getDatabase())) {
$database->delete($database->getDatabase());
@@ -47,28 +48,27 @@
$database->getAuthorization()->addRole(Role::any()->toString());
$database->create();
- $database->createCollection('articles', permissions: [
+ $database->createCollection(new Collection(id: 'articles', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
- ]);
-
- $database->createAttribute('articles', 'author', Database::VAR_STRING, 256, true);
- $database->createAttribute('articles', 'created', Database::VAR_DATETIME, 0, true, filters: ['datetime']);
- $database->createAttribute('articles', 'text', Database::VAR_STRING, 5000, true);
- $database->createAttribute('articles', 'genre', Database::VAR_STRING, 256, true);
- $database->createAttribute('articles', 'views', Database::VAR_INTEGER, 0, true);
- $database->createAttribute('articles', 'tags', Database::VAR_STRING, 0, true, array: true);
- $database->createIndex('articles', 'text', Database::INDEX_FULLTEXT, ['text']);
+ ]));
+
+ $database->createAttribute('articles', Attribute::string(key: 'author', size: 256, required: true));
+ $database->createAttribute('articles', Attribute::datetime(key: 'created', size: 0, required: true, filters: ['datetime']));
+ $database->createAttribute('articles', Attribute::string(key: 'text', size: 5000, required: true));
+ $database->createAttribute('articles', Attribute::string(key: 'genre', size: 256, required: true));
+ $database->createAttribute('articles', Attribute::integer(key: 'views', size: 0, required: true));
+ $database->createAttribute('articles', Attribute::string(key: 'tags', size: 0, required: true, array: true));
+ $database->createIndex('articles', Index::fullText(key: 'text', attributes: ['text']));
};
-
$start = null;
$namespace = '_ns';
$cache = new Cache(new NoCache());
Console::info("Filling {$adapter} with {$limit} records: {$name}");
- //Runtime::enableCoroutine();
+ // Runtime::enableCoroutine();
$dbAdapters = [
'mariadb' => [
@@ -103,15 +103,16 @@
],
];
- if (!isset($dbAdapters[$adapter])) {
+ if (! isset($dbAdapters[$adapter])) {
Console::error("Adapter '{$adapter}' not supported");
+
return;
}
$cfg = $dbAdapters[$adapter];
$dsn = ($cfg['dsn'])($cfg['host'], $cfg['port']);
- //Co\run(function () use (&$start, $limit, $name, $sharedTables, $namespace, $cache, $cfg) {
+ // Co\run(function () use (&$start, $limit, $name, $sharedTables, $namespace, $cache, $cfg) {
$pdo = new PDO(
$dsn,
$cfg['user'],
@@ -132,7 +133,7 @@
->withHost($cfg['host'])
->withPort($cfg['port'])
->withDbName($name)
- //->withCharset('utf8mb4')
+ // ->withCharset('utf8mb4')
->withUsername($cfg['user'])
->withPassword($cfg['pass']),
128
@@ -141,9 +142,9 @@
$start = \microtime(true);
for ($i = 0; $i < $limit / 1000; $i++) {
- //\go(function () use ($cfg, $pool, $name, $namespace, $sharedTables, $cache) {
+ // \go(function () use ($cfg, $pool, $name, $namespace, $sharedTables, $cache) {
try {
- //$pdo = $pool->get();
+ // $pdo = $pool->get();
$database = (new Database(new ($cfg['adapter'])($pdo), $cache))
->setDatabase($name)
@@ -151,19 +152,17 @@
->setSharedTables($sharedTables);
createDocuments($database);
- //$pool->put($pdo);
+ // $pool->put($pdo);
} catch (\Throwable $error) {
- Console::error('Coroutine error: ' . $error->getMessage());
+ Console::error('Coroutine error: '.$error->getMessage());
}
- //});
+ // });
}
$time = microtime(true) - $start;
Console::success("Completed in {$time} seconds");
});
-
-
function createDocuments(Database $database): void
{
global $namesPool, $genresPool, $tagsPool;
@@ -176,16 +175,16 @@ function createDocuments(Database $database): void
$bytes = \random_bytes(intdiv($length + 1, 2));
$text = \substr(\bin2hex($bytes), 0, $length);
$tagCount = \mt_rand(1, count($tagsPool));
- $tagKeys = (array)\array_rand($tagsPool, $tagCount);
+ $tagKeys = (array) \array_rand($tagsPool, $tagCount);
$tags = \array_map(fn ($k) => $tagsPool[$k], $tagKeys);
$documents[] = new Document([
'$permissions' => [
Permission::read(Role::any()),
- ...array_map(fn () => Permission::read(Role::user(mt_rand(0, 999999999))), range(1, 4)),
- ...array_map(fn () => Permission::create(Role::user(mt_rand(0, 999999999))), range(1, 3)),
- ...array_map(fn () => Permission::update(Role::user(mt_rand(0, 999999999))), range(1, 3)),
- ...array_map(fn () => Permission::delete(Role::user(mt_rand(0, 999999999))), range(1, 3)),
+ ...array_map(fn () => Permission::read(Role::user((string) mt_rand(0, 999999999))), range(1, 4)),
+ ...array_map(fn () => Permission::create(Role::user((string) mt_rand(0, 999999999))), range(1, 3)),
+ ...array_map(fn () => Permission::update(Role::user((string) mt_rand(0, 999999999))), range(1, 3)),
+ ...array_map(fn () => Permission::delete(Role::user((string) mt_rand(0, 999999999))), range(1, 3)),
],
'author' => $namesPool[\array_rand($namesPool)],
'created' => DateTime::now(),
diff --git a/bin/tasks/operators.php b/bin/tasks/operators.php
index d351b0ca13..19546be198 100644
--- a/bin/tasks/operators.php
+++ b/bin/tasks/operators.php
@@ -14,16 +14,18 @@
* The --seed parameter allows you to pre-populate the collection with a specified
* number of documents to test how operators perform with varying amounts of existing data.
*/
-
global $cli;
use Utopia\Cache\Adapter\None as NoCache;
use Utopia\Cache\Cache;
use Utopia\Console;
+use Utopia\Database\Adapter\Feature;
use Utopia\Database\Adapter\MariaDB;
use Utopia\Database\Adapter\MySQL;
use Utopia\Database\Adapter\Postgres;
use Utopia\Database\Adapter\SQLite;
+use Utopia\Database\Attribute;
+use Utopia\Database\Collection;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
@@ -41,14 +43,14 @@
->param('adapter', '', new Text(0), 'Database adapter (mariadb, postgres, sqlite)')
->param('iterations', 1000, new Integer(true), 'Number of iterations per test', true)
->param('seed', 0, new Integer(true), 'Number of documents to pre-seed the collection with', true)
- ->param('name', 'operator_benchmark_' . uniqid(), new Text(0), 'Name of test database', true)
+ ->param('name', 'operator_benchmark_'.uniqid(), new Text(0), 'Name of test database', true)
->action(function (string $adapter, int $iterations, int $seed, string $name) {
$namespace = '_ns';
$cache = new Cache(new NoCache());
- Console::info("=============================================================");
- Console::info(" OPERATOR PERFORMANCE BENCHMARK");
- Console::info("=============================================================");
+ Console::info('=============================================================');
+ Console::info(' OPERATOR PERFORMANCE BENCHMARK');
+ Console::info('=============================================================');
Console::info("Adapter: {$adapter}");
Console::info("Iterations: {$iterations}");
Console::info("Seed Documents: {$seed}");
@@ -91,14 +93,15 @@
'port' => 0,
'user' => '',
'pass' => '',
- 'dsn' => static fn (string $host, int $port) => "sqlite::memory:",
+ 'dsn' => static fn (string $host, int $port) => 'sqlite::memory:',
'adapter' => SQLite::class,
'attrs' => [],
],
];
- if (!isset($dbAdapters[$adapter])) {
+ if (! isset($dbAdapters[$adapter])) {
Console::error("Adapter '{$adapter}' not supported. Available: mariadb, postgres, sqlite");
+
return;
}
@@ -128,8 +131,9 @@
Console::success("\nBenchmark completed successfully!");
} catch (\Throwable $e) {
- Console::error("Error: " . $e->getMessage());
- Console::error("Trace: " . $e->getTraceAsString());
+ Console::error('Error: '.$e->getMessage());
+ Console::error('Trace: '.$e->getTraceAsString());
+
return;
}
});
@@ -139,7 +143,7 @@
*/
function setupTestEnvironment(Database $database, string $name, int $seed): void
{
- Console::info("Setting up test environment...");
+ Console::info('Setting up test environment...');
// Delete database if it exists
if ($database->exists($name)) {
@@ -147,41 +151,41 @@ function setupTestEnvironment(Database $database, string $name, int $seed): void
}
$database->create();
- $authorization->addRole(Role::any()->toString());
+ $database->getAuthorization()->addRole(Role::any()->toString());
// Create test collection
- $database->createCollection('operators_test', permissions: [
+ $database->createCollection(new Collection(id: 'operators_test', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
Permission::delete(Role::any()),
- ]);
+ ]));
// Create attributes for all operator types
// Numeric attributes
- $database->createAttribute('operators_test', 'counter', Database::VAR_INTEGER, 0, false, 0);
- $database->createAttribute('operators_test', 'score', Database::VAR_FLOAT, 0, false, 0.0);
- $database->createAttribute('operators_test', 'multiplier', Database::VAR_FLOAT, 0, false, 1.0);
- $database->createAttribute('operators_test', 'divider', Database::VAR_FLOAT, 0, false, 100.0);
- $database->createAttribute('operators_test', 'modulo_val', Database::VAR_INTEGER, 0, false, 100);
- $database->createAttribute('operators_test', 'power_val', Database::VAR_FLOAT, 0, false, 2.0);
+ $database->createAttribute('operators_test', Attribute::integer(key: 'counter', size: 0, required: false, default: 0));
+ $database->createAttribute('operators_test', Attribute::float(key: 'score', size: 0, required: false, default: 0.0));
+ $database->createAttribute('operators_test', Attribute::float(key: 'multiplier', size: 0, required: false, default: 1.0));
+ $database->createAttribute('operators_test', Attribute::float(key: 'divider', size: 0, required: false, default: 100.0));
+ $database->createAttribute('operators_test', Attribute::integer(key: 'modulo_val', size: 0, required: false, default: 100));
+ $database->createAttribute('operators_test', Attribute::float(key: 'power_val', size: 0, required: false, default: 2.0));
// String attributes
- $database->createAttribute('operators_test', 'name', Database::VAR_STRING, 200, false, 'test');
- $database->createAttribute('operators_test', 'text', Database::VAR_STRING, 500, false, 'initial');
- $database->createAttribute('operators_test', 'description', Database::VAR_STRING, 500, false, 'foo bar baz');
+ $database->createAttribute('operators_test', Attribute::string(key: 'name', size: 200, required: false, default: 'test'));
+ $database->createAttribute('operators_test', Attribute::string(key: 'text', size: 500, required: false, default: 'initial'));
+ $database->createAttribute('operators_test', Attribute::string(key: 'description', size: 500, required: false, default: 'foo bar baz'));
// Boolean attributes
- $database->createAttribute('operators_test', 'active', Database::VAR_BOOLEAN, 0, false, true);
+ $database->createAttribute('operators_test', Attribute::boolean(key: 'active', size: 0, required: false, default: true));
// Array attributes
- $database->createAttribute('operators_test', 'tags', Database::VAR_STRING, 50, false, null, true, true);
- $database->createAttribute('operators_test', 'numbers', Database::VAR_INTEGER, 0, false, null, true, true);
- $database->createAttribute('operators_test', 'items', Database::VAR_STRING, 50, false, null, true, true);
+ $database->createAttribute('operators_test', Attribute::string(key: 'tags', size: 50, required: false, default: null, signed: true, array: true));
+ $database->createAttribute('operators_test', Attribute::integer(key: 'numbers', size: 0, required: false, default: null, signed: true, array: true));
+ $database->createAttribute('operators_test', Attribute::string(key: 'items', size: 50, required: false, default: null, signed: true, array: true));
// Date attributes
- $database->createAttribute('operators_test', 'created_at', Database::VAR_DATETIME, 0, false, null, false, false, null, [], ['datetime']);
- $database->createAttribute('operators_test', 'updated_at', Database::VAR_DATETIME, 0, false, null, false, false, null, [], ['datetime']);
+ $database->createAttribute('operators_test', Attribute::datetime(key: 'created_at', size: 0, required: false, default: null, signed: false, array: false, format: null, formatOptions: [], filters: ['datetime']));
+ $database->createAttribute('operators_test', Attribute::datetime(key: 'updated_at', size: 0, required: false, default: null, signed: false, array: false, format: null, formatOptions: [], filters: ['datetime']));
// Seed documents if requested
if ($seed > 0) {
@@ -210,7 +214,7 @@ function seedDocuments(Database $database, int $count): void
for ($i = 0; $i < $remaining; $i++) {
$docNum = ($batch * $batchSize) + $i;
$docs[] = new Document([
- '$id' => 'seed_' . $docNum,
+ '$id' => 'seed_'.$docNum,
'$permissions' => [
Permission::read(Role::any()),
Permission::update(Role::any()),
@@ -221,13 +225,13 @@ function seedDocuments(Database $database, int $count): void
'divider' => round(rand(5000, 15000) / 100, 2),
'modulo_val' => rand(50, 200),
'power_val' => round(rand(100, 300) / 100, 2),
- 'name' => 'seed_doc_' . $docNum,
- 'text' => 'Seed text for document ' . $docNum,
- 'description' => 'This is seed document ' . $docNum . ' with some foo bar baz content',
+ 'name' => 'seed_doc_'.$docNum,
+ 'text' => 'Seed text for document '.$docNum,
+ 'description' => 'This is seed document '.$docNum.' with some foo bar baz content',
'active' => (bool) rand(0, 1),
- 'tags' => ['seed', 'tag' . ($docNum % 10), 'category' . ($docNum % 5)],
+ 'tags' => ['seed', 'tag'.($docNum % 10), 'category'.($docNum % 5)],
'numbers' => [rand(1, 10), rand(11, 20), rand(21, 30)],
- 'items' => ['item' . ($docNum % 3), 'item' . ($docNum % 7)],
+ 'items' => ['item'.($docNum % 3), 'item'.($docNum % 7)],
'created_at' => DateTime::now(),
'updated_at' => DateTime::now(),
]);
@@ -243,7 +247,7 @@ function seedDocuments(Database $database, int $count): void
}
$seedTime = microtime(true) - $seedStart;
- Console::success("Seeding completed in " . number_format($seedTime, 2) . "s\n");
+ Console::success('Seeding completed in '.number_format($seedTime, 2)."s\n");
}
/**
@@ -262,7 +266,7 @@ function runAllBenchmarks(Database $database, int $iterations): array
$results[$name] = $benchmark();
} catch (\Throwable $e) {
$failed[$name] = $e->getMessage();
- Console::warning(" ⚠️ {$name} failed: " . $e->getMessage());
+ Console::warning(" ⚠️ {$name} failed: ".$e->getMessage());
}
};
@@ -343,6 +347,7 @@ function runAllBenchmarks(Database $database, int $iterations): array
Operator::increment(1),
function ($doc) {
$doc->setAttribute('counter', $doc->getAttribute('counter', 0) + 1);
+
return $doc;
},
['counter' => 0]
@@ -356,6 +361,7 @@ function ($doc) {
Operator::decrement(1),
function ($doc) {
$doc->setAttribute('counter', $doc->getAttribute('counter', 100) - 1);
+
return $doc;
},
['counter' => 100]
@@ -369,6 +375,7 @@ function ($doc) {
Operator::multiply(1.1),
function ($doc) {
$doc->setAttribute('multiplier', $doc->getAttribute('multiplier', 1.0) * 1.1);
+
return $doc;
},
['multiplier' => 1.0]
@@ -382,6 +389,7 @@ function ($doc) {
Operator::divide(1.1),
function ($doc) {
$doc->setAttribute('divider', $doc->getAttribute('divider', 100.0) / 1.1);
+
return $doc;
},
['divider' => 100.0]
@@ -396,6 +404,7 @@ function ($doc) {
function ($doc) {
$val = $doc->getAttribute('modulo_val', 100);
$doc->setAttribute('modulo_val', $val % 7);
+
return $doc;
},
['modulo_val' => 100]
@@ -409,6 +418,7 @@ function ($doc) {
Operator::power(1.001),
function ($doc) {
$doc->setAttribute('power_val', pow($doc->getAttribute('power_val', 2.0), 1.001));
+
return $doc;
},
['power_val' => 2.0]
@@ -422,7 +432,8 @@ function ($doc) {
'text',
Operator::stringConcat('x'),
function ($doc) {
- $doc->setAttribute('text', $doc->getAttribute('text', 'initial') . 'x');
+ $doc->setAttribute('text', $doc->getAttribute('text', 'initial').'x');
+
return $doc;
},
['text' => 'initial']
@@ -436,6 +447,7 @@ function ($doc) {
Operator::stringReplace('foo', 'bar'),
function ($doc) {
$doc->setAttribute('description', str_replace('foo', 'bar', $doc->getAttribute('description', 'foo bar baz')));
+
return $doc;
},
['description' => 'foo bar baz']
@@ -449,7 +461,8 @@ function ($doc) {
'active',
Operator::toggle(),
function ($doc) {
- $doc->setAttribute('active', !$doc->getAttribute('active', true));
+ $doc->setAttribute('active', ! $doc->getAttribute('active', true));
+
return $doc;
},
['active' => true]
@@ -466,6 +479,7 @@ function ($doc) {
$tags = $doc->getAttribute('tags', ['initial']);
$tags[] = 'new';
$doc->setAttribute('tags', $tags);
+
return $doc;
},
['tags' => ['initial']]
@@ -481,6 +495,7 @@ function ($doc) {
$tags = $doc->getAttribute('tags', ['initial']);
array_unshift($tags, 'first');
$doc->setAttribute('tags', $tags);
+
return $doc;
},
['tags' => ['initial']]
@@ -496,6 +511,7 @@ function ($doc) {
$numbers = $doc->getAttribute('numbers', [1, 2, 3]);
array_splice($numbers, 1, 0, [99]);
$doc->setAttribute('numbers', $numbers);
+
return $doc;
},
['numbers' => [1, 2, 3]]
@@ -511,6 +527,7 @@ function ($doc) {
$tags = $doc->getAttribute('tags', ['keep', 'unwanted', 'also']);
$tags = array_values(array_filter($tags, fn ($t) => $t !== 'unwanted'));
$doc->setAttribute('tags', $tags);
+
return $doc;
},
['tags' => ['keep', 'unwanted', 'also']]
@@ -525,6 +542,7 @@ function ($doc) {
function ($doc) {
$tags = $doc->getAttribute('tags', ['a', 'b', 'a', 'c', 'b']);
$doc->setAttribute('tags', array_values(array_unique($tags)));
+
return $doc;
},
['tags' => ['a', 'b', 'a', 'c', 'b']]
@@ -539,6 +557,7 @@ function ($doc) {
function ($doc) {
$tags = $doc->getAttribute('tags', ['keep', 'remove', 'this']);
$doc->setAttribute('tags', array_values(array_intersect($tags, ['keep', 'this'])));
+
return $doc;
},
['tags' => ['keep', 'remove', 'this']]
@@ -553,6 +572,7 @@ function ($doc) {
function ($doc) {
$tags = $doc->getAttribute('tags', ['keep', 'remove', 'this']);
$doc->setAttribute('tags', array_values(array_diff($tags, ['remove'])));
+
return $doc;
},
['tags' => ['keep', 'remove', 'this']]
@@ -567,6 +587,7 @@ function ($doc) {
function ($doc) {
$numbers = $doc->getAttribute('numbers', [1, 3, 5, 7, 9]);
$doc->setAttribute('numbers', array_values(array_filter($numbers, fn ($n) => $n > 5)));
+
return $doc;
},
['numbers' => [1, 3, 5, 7, 9]]
@@ -583,6 +604,7 @@ function ($doc) {
$date = new \DateTime($doc->getAttribute('created_at', DateTime::now()));
$date->modify('+1 day');
$doc->setAttribute('created_at', DateTime::format($date));
+
return $doc;
},
['created_at' => DateTime::now()]
@@ -598,6 +620,7 @@ function ($doc) {
$date = new \DateTime($doc->getAttribute('updated_at', DateTime::now()));
$date->modify('-1 day');
$doc->setAttribute('updated_at', DateTime::format($date));
+
return $doc;
},
['updated_at' => DateTime::now()]
@@ -611,16 +634,17 @@ function ($doc) {
Operator::dateSetNow(),
function ($doc) {
$doc->setAttribute('updated_at', DateTime::now());
+
return $doc;
},
['updated_at' => DateTime::now()]
));
// Report any failures
- if (!empty($failed)) {
+ if (! empty($failed)) {
Console::warning("\n⚠️ Some benchmarks failed:");
foreach ($failed as $name => $error) {
- Console::warning(" - {$name}: " . substr($error, 0, 100));
+ Console::warning(" - {$name}: ".substr($error, 0, 100));
}
}
@@ -637,10 +661,10 @@ function benchmarkOperation(
bool $isBulk,
bool $useOperators
): array {
- $displayName = strtoupper($operation) . ($useOperators ? ' (with ops)' : ' (no ops)');
+ $displayName = strtoupper($operation).($useOperators ? ' (with ops)' : ' (no ops)');
Console::info("Benchmarking {$displayName}...");
- $docId = 'bench_op_' . strtolower($operation) . '_' . ($useOperators ? 'ops' : 'noops');
+ $docId = 'bench_op_'.strtolower($operation).'_'.($useOperators ? 'ops' : 'noops');
// Create initial document
$baseData = [
@@ -650,7 +674,7 @@ function benchmarkOperation(
],
'counter' => 0,
'name' => 'test',
- 'score' => 100.0
+ 'score' => 100.0,
];
$database->createDocument('operators_test', new Document(array_merge(['$id' => $docId], $baseData)));
@@ -662,11 +686,11 @@ function benchmarkOperation(
if ($operation === 'updateDocument') {
if ($useOperators) {
$database->updateDocument('operators_test', $docId, new Document([
- 'counter' => Operator::increment(1)
+ 'counter' => Operator::increment(1),
]));
} else {
$database->updateDocument('operators_test', $docId, new Document([
- 'counter' => $i + 1
+ 'counter' => $i + 1,
]));
}
} elseif ($operation === 'updateDocuments') {
@@ -680,7 +704,7 @@ function benchmarkOperation(
// because updateDocuments with queries would apply the same value to all matching docs
$doc = $database->getDocument('operators_test', $docId);
$database->updateDocument('operators_test', $docId, new Document([
- 'counter' => $i + 1
+ 'counter' => $i + 1,
]));
}
} elseif ($operation === 'upsertDocument') {
@@ -689,24 +713,24 @@ function benchmarkOperation(
'$id' => $docId,
'counter' => Operator::increment(1),
'name' => 'test',
- 'score' => 100.0
+ 'score' => 100.0,
]));
} else {
$database->upsertDocument('operators_test', new Document([
'$id' => $docId,
'counter' => $i + 1,
'name' => 'test',
- 'score' => 100.0
+ 'score' => 100.0,
]));
}
} elseif ($operation === 'upsertDocuments') {
if ($useOperators) {
$database->upsertDocuments('operators_test', [
- new Document(['$id' => $docId, 'counter' => Operator::increment(1), 'name' => 'test', 'score' => 100.0])
+ new Document(['$id' => $docId, 'counter' => Operator::increment(1), 'name' => 'test', 'score' => 100.0]),
]);
} else {
$database->upsertDocuments('operators_test', [
- new Document(['$id' => $docId, 'counter' => $i + 1, 'name' => 'test', 'score' => 100.0])
+ new Document(['$id' => $docId, 'counter' => $i + 1, 'name' => 'test', 'score' => 100.0]),
]);
}
}
@@ -718,7 +742,7 @@ function benchmarkOperation(
// Cleanup
$database->deleteDocument('operators_test', $docId);
- Console::success(" Time: {$timeOp}s | Memory: " . formatBytes($memOp));
+ Console::success(" Time: {$timeOp}s | Memory: ".formatBytes($memOp));
return [
'operation' => $operation,
@@ -753,8 +777,9 @@ function benchmarkOperatorAcrossOperations(
foreach ($operationTypes as $opType => $method) {
// Skip upsert operations if not supported
- if (str_contains($method, 'upsert') && !$database->getAdapter()->getSupportForUpserts()) {
+ if (str_contains($method, 'upsert') && ! ($database->getAdapter() instanceof Feature\Upserts)) {
Console::warning(" Skipping {$opType} (not supported by adapter)");
+
continue;
}
@@ -772,7 +797,7 @@ function benchmarkOperatorAcrossOperations(
// Create documents for with-operator test
$docIdsWith = [];
for ($i = 0; $i < $docCount; $i++) {
- $docId = 'bench_with_' . strtolower($operatorName) . '_' . strtolower($opType) . '_' . $i;
+ $docId = 'bench_with_'.strtolower($operatorName).'_'.strtolower($opType).'_'.$i;
$docIdsWith[] = $docId;
$database->createDocument('operators_test', new Document(array_merge(['$id' => $docId], $baseData)));
}
@@ -780,7 +805,7 @@ function benchmarkOperatorAcrossOperations(
// Create documents for without-operator test
$docIdsWithout = [];
for ($i = 0; $i < $docCount; $i++) {
- $docId = 'bench_without_' . strtolower($operatorName) . '_' . strtolower($opType) . '_' . $i;
+ $docId = 'bench_without_'.strtolower($operatorName).'_'.strtolower($opType).'_'.$i;
$docIdsWithout[] = $docId;
$database->createDocument('operators_test', new Document(array_merge(['$id' => $docId], $baseData)));
}
@@ -792,7 +817,7 @@ function benchmarkOperatorAcrossOperations(
for ($i = 0; $i < $iterations; $i++) {
if ($method === 'updateDocument') {
$database->updateDocument('operators_test', $docIdsWith[0], new Document([
- $attribute => $operator
+ $attribute => $operator,
]));
} elseif ($method === 'updateDocuments') {
$updates = new Document([$attribute => $operator]);
@@ -915,8 +940,8 @@ function benchmarkOperatorAcrossOperations(
function displayResults(array $results, string $adapter, int $iterations, int $seed): void
{
Console::info("\n=============================================================");
- Console::info(" BENCHMARK RESULTS");
- Console::info("=============================================================");
+ Console::info(' BENCHMARK RESULTS');
+ Console::info('=============================================================');
Console::info("Adapter: {$adapter}");
Console::info("Iterations per test: {$iterations}");
Console::info("Seeded documents: {$seed}");
@@ -931,8 +956,8 @@ function displayResults(array $results, string $adapter, int $iterations, int $s
$opTypes = ['UPDATE_SINGLE', 'UPDATE_BULK', 'UPSERT_SINGLE', 'UPSERT_BULK'];
foreach ($opTypes as $opType) {
- $noOpsKey = $opType . '_NO_OPS';
- $withOpsKey = $opType . '_WITH_OPS';
+ $noOpsKey = $opType.'_NO_OPS';
+ $withOpsKey = $opType.'_WITH_OPS';
if (isset($results[$noOpsKey]) && isset($results[$withOpsKey])) {
$noOps = $results[$noOpsKey];
@@ -941,10 +966,10 @@ function displayResults(array $results, string $adapter, int $iterations, int $s
$timeNoOps = number_format($noOps['time'], 4);
$timeWithOps = number_format($withOps['time'], 4);
- Console::info(str_pad($opType, 20) . ":");
+ Console::info(str_pad($opType, 20).':');
Console::info(" NO operators: {$timeNoOps}s");
Console::info(" WITH operators: {$timeWithOps}s");
- Console::info("");
+ Console::info('');
}
}
@@ -990,7 +1015,7 @@ function displayResults(array $results, string $adapter, int $iterations, int $s
Console::info("\n{$categoryName} Operators:");
foreach ($operators as $operatorName) {
- if (!isset($results[$operatorName])) {
+ if (! isset($results[$operatorName])) {
continue;
}
@@ -998,8 +1023,9 @@ function displayResults(array $results, string $adapter, int $iterations, int $s
Console::info("\n {$operatorName}:");
- if (!isset($result['operations'])) {
- Console::warning(" No results (benchmark failed)");
+ if (! isset($result['operations'])) {
+ Console::warning(' No results (benchmark failed)');
+
continue;
}
@@ -1040,14 +1066,14 @@ function displayResults(array $results, string $adapter, int $iterations, int $s
// Summary statistics
$avgSpeedup = $totalCount > 0 ? $totalSpeedup / $totalCount : 0;
- Console::info("\n" . str_repeat('=', array_sum($colWidths) + 5));
- Console::info("SUMMARY:");
+ Console::info("\n".str_repeat('=', array_sum($colWidths) + 5));
+ Console::info('SUMMARY:');
Console::info(" Total operators tested: {$totalCount}");
- Console::info(" Average speedup: " . number_format($avgSpeedup, 2) . "x");
+ Console::info(' Average speedup: '.number_format($avgSpeedup, 2).'x');
// Performance insights
- Console::info("\n" . str_repeat('=', array_sum($colWidths) + 5));
- Console::info("PERFORMANCE INSIGHTS:");
+ Console::info("\n".str_repeat('=', array_sum($colWidths) + 5));
+ Console::info('PERFORMANCE INSIGHTS:');
// Flatten results for fastest/slowest calculation
$flattenedResults = [];
@@ -1063,25 +1089,23 @@ function displayResults(array $results, string $adapter, int $iterations, int $s
}
}
- if (!empty($flattenedResults)) {
+ if (! empty($flattenedResults)) {
$fastest = array_reduce(
$flattenedResults,
- fn ($carry, $item) =>
- $carry === null || $item['speedup'] > $carry['speedup'] ? $item : $carry
+ fn ($carry, $item) => $carry === null || $item['speedup'] > $carry['speedup'] ? $item : $carry
);
$slowest = array_reduce(
$flattenedResults,
- fn ($carry, $item) =>
- $carry === null || $item['speedup'] < $carry['speedup'] ? $item : $carry
+ fn ($carry, $item) => $carry === null || $item['speedup'] < $carry['speedup'] ? $item : $carry
);
if ($fastest) {
- Console::success(" Fastest: {$fastest['operator']} ({$fastest['operation']}) - " . number_format($fastest['speedup'], 2) . "x speedup");
+ Console::success(" Fastest: {$fastest['operator']} ({$fastest['operation']}) - ".number_format($fastest['speedup'], 2).'x speedup');
}
if ($slowest) {
- Console::warning(" Slowest: {$slowest['operator']} ({$slowest['operation']}) - " . number_format($slowest['speedup'], 2) . "x speedup");
+ Console::warning(" Slowest: {$slowest['operator']} ({$slowest['operation']}) - ".number_format($slowest['speedup'], 2).'x speedup');
}
}
@@ -1104,7 +1128,7 @@ function formatBytes(int $bytes): string
$power = floor(log($bytes, 1024));
$power = min($power, count($units) - 1);
- return $sign . round($bytes / pow(1024, $power), 2) . ' ' . $units[$power];
+ return $sign.round($bytes / pow(1024, $power), 2).' '.$units[$power];
}
/**
@@ -1112,14 +1136,14 @@ function formatBytes(int $bytes): string
*/
function cleanup(Database $database, string $name): void
{
- Console::info("Cleaning up test environment...");
+ Console::info('Cleaning up test environment...');
try {
if ($database->exists($name)) {
$database->delete($name);
}
- Console::success("Cleanup complete.");
+ Console::success('Cleanup complete.');
} catch (\Throwable $e) {
- Console::warning("Cleanup failed: " . $e->getMessage());
+ Console::warning('Cleanup failed: '.$e->getMessage());
}
}
diff --git a/bin/tasks/query.php b/bin/tasks/query.php
index d6c9987142..c825bd2b27 100644
--- a/bin/tasks/query.php
+++ b/bin/tasks/query.php
@@ -24,7 +24,6 @@
* @Example
* docker compose exec tests bin/query --adapter=mariadb --limit=1000 --name=testing
*/
-
$cli
->task('query')
->desc('Query mock data')
@@ -38,6 +37,7 @@
for ($i = 0; $i < $count; $i++) {
$authorization->addRole($faker->numerify('user####'));
}
+
return \count($authorization->getRoles());
};
@@ -77,8 +77,9 @@
],
];
- if (!isset($dbAdapters[$adapter])) {
+ if (! isset($dbAdapters[$adapter])) {
Console::error("Adapter '{$adapter}' not supported");
+
return;
}
@@ -104,38 +105,38 @@
Console::info("\nRunning queries with {$count} authorization roles:");
$report[] = [
'roles' => $count,
- 'results' => runQueries($database, $limit)
+ 'results' => runQueries($database, $limit),
];
$count = $setRoles($database->getAuthorization(), $faker, 100);
Console::info("\nRunning queries with {$count} authorization roles:");
$report[] = [
'roles' => $count,
- 'results' => runQueries($database, $limit)
+ 'results' => runQueries($database, $limit),
];
$count = $setRoles($database->getAuthorization(), $faker, 400);
Console::info("\nRunning queries with {$count} authorization roles:");
$report[] = [
'roles' => $count,
- 'results' => runQueries($database, $limit)
+ 'results' => runQueries($database, $limit),
];
$count = $setRoles($database->getAuthorization(), $faker, 500);
Console::info("\nRunning queries with {$count} authorization roles:");
$report[] = [
'roles' => $count,
- 'results' => runQueries($database, $limit)
+ 'results' => runQueries($database, $limit),
];
$count = $setRoles($database->getAuthorization(), $faker, 1000);
Console::info("\nRunning queries with {$count} authorization roles:");
$report[] = [
'roles' => $count,
- 'results' => runQueries($database, $limit)
+ 'results' => runQueries($database, $limit),
];
- if (!file_exists('bin/view/results')) {
+ if (! file_exists('bin/view/results')) {
\mkdir('bin/view/results', 0777, true);
}
@@ -145,40 +146,39 @@
\fclose($results);
});
-
function runQueries(Database $database, int $limit): array
{
$results = [];
// Recent travel blogs
- $results["Querying greater than, equal[1] and limit"] = runQuery([
+ $results['Querying greater than, equal[1] and limit'] = runQuery([
Query::greaterThan('created', '2010-01-01 05:00:00'),
Query::equal('genre', ['travel']),
- Query::limit($limit)
+ Query::limit($limit),
], $database);
// Favorite genres
- $results["Querying equal[3] and limit"] = runQuery([
+ $results['Querying equal[3] and limit'] = runQuery([
Query::equal('genre', ['fashion', 'finance', 'sports']),
- Query::limit($limit)
+ Query::limit($limit),
], $database);
// Popular posts
$results["Querying greaterThan, limit({$limit})"] = runQuery([
Query::greaterThan('views', 100000),
- Query::limit($limit)
+ Query::limit($limit),
], $database);
// Fulltext search
$results["Query search, limit({$limit})"] = runQuery([
Query::search('text', 'Alice'),
- Query::limit($limit)
+ Query::limit($limit),
], $database);
// Tags contain query
$results["Querying contains[1], limit({$limit})"] = runQuery([
Query::contains('tags', ['tag1']),
- Query::limit($limit)
+ Query::limit($limit),
], $database);
return $results;
@@ -187,13 +187,14 @@ function runQueries(Database $database, int $limit): array
function runQuery(array $query, Database $database)
{
$info = array_map(function (Query $q) {
- return $q->getAttribute() . ': ' . $q->getMethod() . ' = ' . implode(',', $q->getValues());
+ return $q->getAttribute().': '.$q->getMethod()->value.' = '.implode(',', $q->getValues());
}, $query);
- Console::info("Running query: [" . implode(', ', $info) . "]");
+ Console::info('Running query: ['.implode(', ', $info).']');
$start = microtime(true);
$database->find('articles', $query);
$time = microtime(true) - $start;
Console::success("Query executed in {$time} seconds");
+
return $time;
}
diff --git a/bin/tasks/relationships.php b/bin/tasks/relationships.php
index 790845b9cd..3bbf39c8f8 100644
--- a/bin/tasks/relationships.php
+++ b/bin/tasks/relationships.php
@@ -13,6 +13,8 @@
use Utopia\Database\Adapter\MariaDB;
use Utopia\Database\Adapter\MySQL;
use Utopia\Database\Adapter\Postgres;
+use Utopia\Database\Attribute;
+use Utopia\Database\Collection;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
@@ -20,6 +22,9 @@
use Utopia\Database\Helpers\Role;
use Utopia\Database\PDO;
use Utopia\Database\Query;
+use Utopia\Database\Relationship;
+use Utopia\Database\RelationType;
+use Utopia\Query\Schema\ForeignKeyAction;
use Utopia\Validator\Boolean;
use Utopia\Validator\Integer;
use Utopia\Validator\Text;
@@ -33,13 +38,12 @@
* @Example
* docker compose exec tests bin/relationships --adapter=mariadb --limit=1000
*/
-
$cli
->task('relationships')
->desc('Load database with mock relationships for testing')
->param('adapter', '', new Text(0), 'Database adapter')
->param('limit', 0, new Integer(true), 'Total number of records to add to database')
- ->param('name', 'myapp_' . uniqid(), new Text(0), 'Name of created database.', true)
+ ->param('name', 'myapp_'.uniqid(), new Text(0), 'Name of created database.', true)
->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true)
->param('runs', 1, new Integer(true), 'Number of times to run benchmarks', true)
->action(function (string $adapter, int $limit, string $name, bool $sharedTables, int $runs) {
@@ -55,67 +59,67 @@
}
$database->getAuthorization()->addRole(Role::any()->toString());
$database->create();
- $database->createCollection('authors', permissions: [
+ $database->createCollection(new Collection(id: 'authors', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
- ]);
- $database->createAttribute('authors', 'name', Database::VAR_STRING, 256, true);
- $database->createAttribute('authors', 'created', Database::VAR_DATETIME, 0, true, filters: ['datetime']);
- $database->createAttribute('authors', 'bio', Database::VAR_STRING, 5000, true);
- $database->createAttribute('authors', 'avatar', Database::VAR_STRING, 256, true);
- $database->createAttribute('authors', 'website', Database::VAR_STRING, 256, true);
-
- $database->createCollection('articles', permissions: [
+ ]));
+ $database->createAttribute('authors', Attribute::string(key: 'name', size: 256, required: true));
+ $database->createAttribute('authors', Attribute::datetime(key: 'created', size: 0, required: true, filters: ['datetime']));
+ $database->createAttribute('authors', Attribute::string(key: 'bio', size: 5000, required: true));
+ $database->createAttribute('authors', Attribute::string(key: 'avatar', size: 256, required: true));
+ $database->createAttribute('authors', Attribute::string(key: 'website', size: 256, required: true));
+
+ $database->createCollection(new Collection(id: 'articles', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
- ]);
- $database->createAttribute('articles', 'title', Database::VAR_STRING, 256, true);
- $database->createAttribute('articles', 'text', Database::VAR_STRING, 5000, true);
- $database->createAttribute('articles', 'genre', Database::VAR_STRING, 256, true);
- $database->createAttribute('articles', 'views', Database::VAR_INTEGER, 0, true);
- $database->createAttribute('articles', 'tags', Database::VAR_STRING, 0, true, array: true);
-
- $database->createCollection('users', permissions: [
+ ]));
+ $database->createAttribute('articles', Attribute::string(key: 'title', size: 256, required: true));
+ $database->createAttribute('articles', Attribute::string(key: 'text', size: 5000, required: true));
+ $database->createAttribute('articles', Attribute::string(key: 'genre', size: 256, required: true));
+ $database->createAttribute('articles', Attribute::integer(key: 'views', size: 0, required: true));
+ $database->createAttribute('articles', Attribute::string(key: 'tags', size: 0, required: true, array: true));
+
+ $database->createCollection(new Collection(id: 'users', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
- ]);
- $database->createAttribute('users', 'username', Database::VAR_STRING, 256, true);
- $database->createAttribute('users', 'email', Database::VAR_STRING, 256, true);
- $database->createAttribute('users', 'password', Database::VAR_STRING, 256, true);
+ ]));
+ $database->createAttribute('users', Attribute::string(key: 'username', size: 256, required: true));
+ $database->createAttribute('users', Attribute::string(key: 'email', size: 256, required: true));
+ $database->createAttribute('users', Attribute::string(key: 'password', size: 256, required: true));
- $database->createCollection('comments', permissions: [
+ $database->createCollection(new Collection(id: 'comments', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
- ]);
- $database->createAttribute('comments', 'content', Database::VAR_STRING, 256, true);
- $database->createAttribute('comments', 'likes', Database::VAR_INTEGER, 8, true, signed: false);
+ ]));
+ $database->createAttribute('comments', Attribute::string(key: 'content', size: 256, required: true));
+ $database->createAttribute('comments', Attribute::integer(key: 'likes', size: 8, required: true, signed: false));
- $database->createCollection('profiles', permissions: [
+ $database->createCollection(new Collection(id: 'profiles', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
- ]);
- $database->createAttribute('profiles', 'bio_extended', Database::VAR_STRING, 10000, true);
- $database->createAttribute('profiles', 'social_links', Database::VAR_STRING, 256, true, array: true);
- $database->createAttribute('profiles', 'verified', Database::VAR_BOOLEAN, 0, true);
+ ]));
+ $database->createAttribute('profiles', Attribute::string(key: 'bio_extended', size: 10000, required: true));
+ $database->createAttribute('profiles', Attribute::string(key: 'social_links', size: 256, required: true, array: true));
+ $database->createAttribute('profiles', Attribute::boolean(key: 'verified', size: 0, required: true));
- $database->createCollection('categories', permissions: [
+ $database->createCollection(new Collection(id: 'categories', permissions: [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
- ]);
- $database->createAttribute('categories', 'name', Database::VAR_STRING, 256, true);
- $database->createAttribute('categories', 'description', Database::VAR_STRING, 1000, true);
-
- $database->createRelationship('authors', 'articles', Database::RELATION_MANY_TO_MANY, true, onDelete: Database::RELATION_MUTATE_SET_NULL);
- $database->createRelationship('articles', 'comments', Database::RELATION_ONE_TO_MANY, true, twoWayKey: 'article', onDelete: Database::RELATION_MUTATE_CASCADE);
- $database->createRelationship('users', 'comments', Database::RELATION_ONE_TO_MANY, true, twoWayKey: 'user', onDelete: Database::RELATION_MUTATE_CASCADE);
- $database->createRelationship('authors', 'profiles', Database::RELATION_ONE_TO_ONE, true, twoWayKey: 'author', onDelete: Database::RELATION_MUTATE_CASCADE);
- $database->createRelationship('articles', 'categories', Database::RELATION_MANY_TO_ONE, true, id: 'category', twoWayKey: 'articles', onDelete: Database::RELATION_MUTATE_SET_NULL);
+ ]));
+ $database->createAttribute('categories', Attribute::string(key: 'name', size: 256, required: true));
+ $database->createAttribute('categories', Attribute::string(key: 'description', size: 1000, required: true));
+
+ $database->createRelationship(new Relationship(collection: 'authors', relatedCollection: 'articles', type: RelationType::ManyToMany, twoWay: true, onDelete: ForeignKeyAction::SetNull));
+ $database->createRelationship(new Relationship(collection: 'articles', relatedCollection: 'comments', type: RelationType::OneToMany, twoWay: true, twoWayKey: 'article', onDelete: ForeignKeyAction::Cascade));
+ $database->createRelationship(new Relationship(collection: 'users', relatedCollection: 'comments', type: RelationType::OneToMany, twoWay: true, twoWayKey: 'user', onDelete: ForeignKeyAction::Cascade));
+ $database->createRelationship(new Relationship(collection: 'authors', relatedCollection: 'profiles', type: RelationType::OneToOne, twoWay: true, twoWayKey: 'author', onDelete: ForeignKeyAction::Cascade));
+ $database->createRelationship(new Relationship(collection: 'articles', relatedCollection: 'categories', type: RelationType::ManyToOne, twoWay: true, key: 'category', twoWayKey: 'articles', onDelete: ForeignKeyAction::SetNull));
};
$dbAdapters = [
@@ -148,8 +152,9 @@
],
];
- if (!isset($dbAdapters[$adapter])) {
+ if (! isset($dbAdapters[$adapter])) {
Console::error("Adapter '{$adapter}' not supported");
+
return;
}
@@ -234,20 +239,19 @@
displayBenchmarkResults($results, $runs);
});
-
function createGlobalDocuments(Database $database, int $limit): array
{
global $genresPool, $namesPool;
// Scale categories based on limit (minimum 9, scales up to 100 max)
- $numCategories = min(100, max(9, (int)($limit / 10000)));
+ $numCategories = min(100, max(9, (int) ($limit / 10000)));
$categoryDocs = [];
for ($i = 0; $i < $numCategories; $i++) {
$genre = $genresPool[$i % count($genresPool)];
$categoryDocs[] = new Document([
- '$id' => 'category_' . \uniqid(),
- 'name' => \ucfirst($genre) . ($i >= count($genresPool) ? ' ' . ($i + 1) : ''),
- 'description' => 'Articles about ' . $genre,
+ '$id' => 'category_'.\uniqid(),
+ 'name' => \ucfirst($genre).($i >= count($genresPool) ? ' '.($i + 1) : ''),
+ 'description' => 'Articles about '.$genre,
]);
}
@@ -255,13 +259,13 @@ function createGlobalDocuments(Database $database, int $limit): array
$database->createDocuments('categories', $categoryDocs);
// Scale users based on limit (10% of total documents)
- $numUsers = max(1000, (int)($limit / 10));
+ $numUsers = max(1000, (int) ($limit / 10));
$userDocs = [];
for ($u = 0; $u < $numUsers; $u++) {
$userDocs[] = new Document([
- '$id' => 'user_' . \uniqid(),
- 'username' => $namesPool[\array_rand($namesPool)] . '_' . $u,
- 'email' => 'user' . $u . '@example.com',
+ '$id' => 'user_'.\uniqid(),
+ 'username' => $namesPool[\array_rand($namesPool)].'_'.$u,
+ 'email' => 'user'.$u.'@example.com',
'password' => \bin2hex(\random_bytes(8)),
]);
}
@@ -291,18 +295,18 @@ function createRelationshipDocuments(Database $database, array $categories, arra
'name' => $namesPool[array_rand($namesPool)],
'created' => DateTime::now(),
'bio' => \substr(\bin2hex(\random_bytes(32)), 0, 100),
- 'avatar' => 'https://example.com/avatar/' . $a,
- 'website' => 'https://example.com/user/' . $a,
+ 'avatar' => 'https://example.com/avatar/'.$a,
+ 'website' => 'https://example.com/user/'.$a,
]);
// Create profile for author (one-to-one relationship)
$profile = new Document([
'bio_extended' => \substr(\bin2hex(\random_bytes(128)), 0, 500),
'social_links' => [
- 'https://twitter.com/author' . $a,
- 'https://linkedin.com/in/author' . $a,
+ 'https://twitter.com/author'.$a,
+ 'https://linkedin.com/in/author'.$a,
],
- 'verified' => (bool)\mt_rand(0, 1),
+ 'verified' => (bool) \mt_rand(0, 1),
]);
$author->setAttribute('profiles', $profile);
@@ -310,7 +314,7 @@ function createRelationshipDocuments(Database $database, array $categories, arra
$authorArticles = [];
for ($i = 0; $i < $numArticlesPerAuthor; $i++) {
$article = new Document([
- 'title' => 'Article ' . ($i + 1) . ' by ' . $author->getAttribute('name'),
+ 'title' => 'Article '.($i + 1).' by '.$author->getAttribute('name'),
'text' => \substr(\bin2hex(\random_bytes(64)), 0, \mt_rand(100, 200)),
'genre' => $genresPool[array_rand($genresPool)],
'views' => \mt_rand(0, 1000),
@@ -322,7 +326,7 @@ function createRelationshipDocuments(Database $database, array $categories, arra
$comments = [];
for ($c = 0; $c < $numCommentsPerArticle; $c++) {
$comment = new Document([
- 'content' => 'Comment ' . ($c + 1),
+ 'content' => 'Comment '.($c + 1),
'likes' => \mt_rand(0, 10000),
'user' => $users[\array_rand($users)],
]);
@@ -463,36 +467,36 @@ function benchmarkPagination(Database $database): array
function displayRelationshipStructure(): void
{
Console::success("\n========================================");
- Console::success("Relationship Structure");
+ Console::success('Relationship Structure');
Console::success("========================================\n");
- Console::info("Collections:");
- Console::log(" • authors (name, created, bio, avatar, website)");
- Console::log(" • articles (title, text, genre, views, tags[])");
- Console::log(" • comments (content, likes)");
- Console::log(" • users (username, email, password)");
- Console::log(" • profiles (bio_extended, social_links[], verified)");
- Console::log(" • categories (name, description)");
- Console::log("");
-
- Console::info("Relationships:");
- Console::log(" ┌─────────────────────────────────────────────────────────────┐");
- Console::log(" │ authors ◄─────────────► articles (Many-to-Many) │");
- Console::log(" │ └─► profiles (One-to-One) │");
- Console::log(" │ │");
- Console::log(" │ articles ─────────────► comments (One-to-Many) │");
- Console::log(" │ └─► categories (Many-to-One) │");
- Console::log(" │ │");
- Console::log(" │ users ────────────────► comments (One-to-Many) │");
- Console::log(" └─────────────────────────────────────────────────────────────┘");
- Console::log("");
-
- Console::info("Relationship Coverage:");
- Console::log(" ✓ One-to-One: authors ◄─► profiles");
- Console::log(" ✓ One-to-Many: articles ─► comments, users ─► comments");
- Console::log(" ✓ Many-to-One: articles ─► categories");
- Console::log(" ✓ Many-to-Many: authors ◄─► articles");
- Console::log("");
+ Console::info('Collections:');
+ Console::log(' • authors (name, created, bio, avatar, website)');
+ Console::log(' • articles (title, text, genre, views, tags[])');
+ Console::log(' • comments (content, likes)');
+ Console::log(' • users (username, email, password)');
+ Console::log(' • profiles (bio_extended, social_links[], verified)');
+ Console::log(' • categories (name, description)');
+ Console::log('');
+
+ Console::info('Relationships:');
+ Console::log(' ┌─────────────────────────────────────────────────────────────┐');
+ Console::log(' │ authors ◄─────────────► articles (Many-to-Many) │');
+ Console::log(' │ └─► profiles (One-to-One) │');
+ Console::log(' │ │');
+ Console::log(' │ articles ─────────────► comments (One-to-Many) │');
+ Console::log(' │ └─► categories (Many-to-One) │');
+ Console::log(' │ │');
+ Console::log(' │ users ────────────────► comments (One-to-Many) │');
+ Console::log(' └─────────────────────────────────────────────────────────────┘');
+ Console::log('');
+
+ Console::info('Relationship Coverage:');
+ Console::log(' ✓ One-to-One: authors ◄─► profiles');
+ Console::log(' ✓ One-to-Many: articles ─► comments, users ─► comments');
+ Console::log(' ✓ Many-to-One: articles ─► categories');
+ Console::log(' ✓ Many-to-Many: authors ◄─► articles');
+ Console::log('');
}
/**
@@ -524,7 +528,7 @@ function displayBenchmarkResults(array $results, int $runs): void
}
Console::success("\n========================================");
- Console::success("Benchmark Results (Average of {$runs} run" . ($runs > 1 ? 's' : '') . ")");
+ Console::success("Benchmark Results (Average of {$runs} run".($runs > 1 ? 's' : '').')');
Console::success("========================================\n");
// Calculate column widths
@@ -532,19 +536,19 @@ function displayBenchmarkResults(array $results, int $runs): void
$timeWidth = 12;
// Print header
- $header = str_pad('Collection', $collectionWidth) . ' | ';
+ $header = str_pad('Collection', $collectionWidth).' | ';
foreach ($benchmarkLabels as $label) {
- $header .= str_pad($label, $timeWidth) . ' | ';
+ $header .= str_pad($label, $timeWidth).' | ';
}
Console::info($header);
Console::info(str_repeat('-', strlen($header)));
// Print results for each collection
foreach ($collections as $collection) {
- $row = str_pad(ucfirst($collection), $collectionWidth) . ' | ';
+ $row = str_pad(ucfirst($collection), $collectionWidth).' | ';
foreach ($benchmarks as $benchmark) {
$time = number_format($averages[$benchmark][$collection] * 1000, 2); // Convert to ms
- $row .= str_pad($time . ' ms', $timeWidth) . ' | ';
+ $row .= str_pad($time.' ms', $timeWidth).' | ';
}
Console::log($row);
}
diff --git a/bin/view/index.php b/bin/view/index.php
index 4afb1e6775..57091f586e 100644
--- a/bin/view/index.php
+++ b/bin/view/index.php
@@ -38,12 +38,12 @@
const results = $path,
- 'data' => \json_decode(\file_get_contents("{$directory}/{$path}"), true)
+ 'data' => \json_decode(\file_get_contents("{$directory}/{$path}"), true),
];
}
diff --git a/composer.json b/composer.json
index b50da19f3a..65bff51dec 100755
--- a/composer.json
+++ b/composer.json
@@ -4,7 +4,8 @@
"type": "library",
"keywords": ["php","framework", "upf", "utopia", "database"],
"license": "MIT",
- "minimum-stability": "stable",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
"autoload": {
"psr-4": {"Utopia\\Database\\": "src/Database"}
},
@@ -25,11 +26,11 @@
],
"test": [
"Composer\\Config::disableProcessTimeout",
- "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml"
+ "docker compose exec tests vendor/bin/paratest --configuration phpunit.xml --functional --processes 4"
],
"lint": "php -d memory_limit=2G ./vendor/bin/pint --test",
"format": "php -d memory_limit=2G ./vendor/bin/pint",
- "check": "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G",
+ "check": "./vendor/bin/phpstan analyse --memory-limit 2G",
"coverage": "./vendor/bin/coverage-check ./tmp/clover.xml 90"
},
"require": {
@@ -42,24 +43,37 @@
"utopia-php/console": "0.1.*",
"utopia-php/cache": "^4.0 || ^5.0",
"utopia-php/pools": "2.*",
- "utopia-php/mongo": "1.*"
+ "utopia-php/mongo": "1.*",
+ "utopia-php/query": "0.6.*",
+ "utopia-php/async": "^0.1"
},
"require-dev": {
"fakerphp/faker": "1.23.*",
- "phpunit/phpunit": "9.*",
- "pcov/clobber": "2.*",
+ "phpunit/phpunit": "12.5.*",
+ "brianium/paratest": "7.20.*",
"swoole/ide-helper": "5.1.3",
- "utopia-php/cli": "0.22.*",
+ "utopia-php/cli": "^0.22",
"laravel/pint": "*",
- "phpstan/phpstan": "1.*",
- "rregeer/phpunit-coverage-check": "0.3.*"
+ "phpstan/phpstan": "2.1.*",
+ "rregeer/phpunit-coverage-check": "0.3.*",
+ "phpstan/phpstan-phpunit": "2.0.*"
},
- "suggests": {
+ "suggest": {
"ext-redis": "Needed to support Redis Cache Adapter",
"ext-pdo": "Needed to support MariaDB, MySQL or SQLite Database Adapter",
"mongodb/mongodb": "Needed to support MongoDB Database Adapter"
},
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/utopia-php/async.git"
+ },
+ {
+ "type": "vcs",
+ "url": "https://github.com/utopia-php/query.git"
+ }
+ ],
"config": {
"allow-plugins": {
"php-http/discovery": false,
diff --git a/composer.lock b/composer.lock
index d299d53e0c..9bfcd201d8 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,26 +4,27 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "de1a56a5d39a51711c1f26fcbcf47bdc",
+ "content-hash": "58350c12e750e8d51dc3a5660ab81c60",
"packages": [
{
"name": "brick/math",
- "version": "0.18.0",
+ "version": "0.14.8",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
- "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad"
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad",
- "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad",
+ "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
"shasum": ""
},
"require": {
"php": "^8.2"
},
"require-dev": {
+ "php-coveralls/php-coveralls": "^2.2",
"phpstan/phpstan": "2.1.22",
"phpunit/phpunit": "^11.5"
},
@@ -55,7 +56,7 @@
],
"support": {
"issues": "https://github.com/brick/math/issues",
- "source": "https://github.com/brick/math/tree/0.18.0"
+ "source": "https://github.com/brick/math/tree/0.14.8"
},
"funding": [
{
@@ -63,7 +64,7 @@
"type": "github"
}
],
- "time": "2026-06-14T18:21:03+00:00"
+ "time": "2026-02-10T14:33:43+00:00"
},
{
"name": "composer/semver",
@@ -144,23 +145,23 @@
},
{
"name": "google/protobuf",
- "version": "v5.35.1",
+ "version": "v4.33.6",
"source": {
"type": "git",
"url": "https://github.com/protocolbuffers/protobuf-php.git",
- "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb"
+ "reference": "84b008c23915ed94536737eae46f41ba3bccfe67"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/55bb4a7d6739b5af0927b96213c1371a3afb7cfb",
- "reference": "55bb4a7d6739b5af0927b96213c1371a3afb7cfb",
+ "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67",
+ "reference": "84b008c23915ed94536737eae46f41ba3bccfe67",
"shasum": ""
},
"require": {
- "php": ">=8.2.0"
+ "php": ">=8.1.0"
},
"require-dev": {
- "phpunit/phpunit": ">=11.5.0 <12.0.0"
+ "phpunit/phpunit": ">=10.5.62 <11.0.0"
},
"suggest": {
"ext-bcmath": "Need to support JSON deserialization"
@@ -182,9 +183,9 @@
"proto"
],
"support": {
- "source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.35.1"
+ "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6"
},
- "time": "2026-06-11T21:19:23+00:00"
+ "time": "2026-03-18T17:32:05+00:00"
},
{
"name": "mongodb/mongodb",
@@ -409,16 +410,16 @@
},
{
"name": "open-telemetry/api",
- "version": "1.10.0",
+ "version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/api.git",
- "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559"
+ "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7c029c4a6fd457094a20569bf98f93d95e9a7559",
- "reference": "7c029c4a6fd457094a20569bf98f93d95e9a7559",
+ "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be",
+ "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be",
"shasum": ""
},
"require": {
@@ -475,7 +476,7 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2026-07-06T12:28:04+00:00"
+ "time": "2026-02-25T13:24:05+00:00"
},
{
"name": "open-telemetry/context",
@@ -602,20 +603,20 @@
},
{
"name": "open-telemetry/gen-otlp-protobuf",
- "version": "1.10.0",
+ "version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git",
- "reference": "66f04d0e448ad333033bfc7baae1aa56330be088"
+ "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/66f04d0e448ad333033bfc7baae1aa56330be088",
- "reference": "66f04d0e448ad333033bfc7baae1aa56330be088",
+ "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9",
+ "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9",
"shasum": ""
},
"require": {
- "google/protobuf": "^3.22 || ^4.0 || ^5.0",
+ "google/protobuf": "^3.22 || ^4.0",
"php": "^8.0"
},
"suggest": {
@@ -661,20 +662,20 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2026-06-17T12:06:32+00:00"
+ "time": "2025-10-19T06:44:33+00:00"
},
{
"name": "open-telemetry/sdk",
- "version": "1.15.0",
+ "version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/opentelemetry-php/sdk.git",
- "reference": "77e1aa73850154abb86937d52a70883edc3b4547"
+ "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/77e1aa73850154abb86937d52a70883edc3b4547",
- "reference": "77e1aa73850154abb86937d52a70883edc3b4547",
+ "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
+ "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
"shasum": ""
},
"require": {
@@ -682,7 +683,7 @@
"nyholm/psr7-server": "^1.1",
"open-telemetry/api": "^1.8",
"open-telemetry/context": "^1.4",
- "open-telemetry/sem-conv": "^1.38.0",
+ "open-telemetry/sem-conv": "^1.0",
"php": "^8.1",
"php-http/discovery": "^1.14",
"psr/http-client": "^1.0",
@@ -705,10 +706,7 @@
"spi": {
"OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [
"OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig",
- "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig",
- "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySemConv",
- "OpenTelemetry\\SDK\\ConfigEnv\\Trace\\SpanSuppressionStrategySpanKind",
- "OpenTelemetry\\SDK\\ConfigEnv\\Distribution\\DistributionConfigurationSdk"
+ "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig"
],
"OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [
"OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver"
@@ -718,7 +716,7 @@
]
},
"branch-alias": {
- "dev-main": "1.14.x-dev"
+ "dev-main": "1.12.x-dev"
}
},
"autoload": {
@@ -761,7 +759,7 @@
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
"source": "https://github.com/open-telemetry/opentelemetry-php"
},
- "time": "2026-07-14T13:09:54+00:00"
+ "time": "2026-03-21T11:50:01+00:00"
},
{
"name": "open-telemetry/sem-conv",
@@ -820,6 +818,71 @@
},
"time": "2026-01-21T04:14:03+00:00"
},
+ {
+ "name": "opis/closure",
+ "version": "4.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opis/closure.git",
+ "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opis/closure/zipball/b97e42b95bb72d87507f5e2d137ceb239aea8d6b",
+ "reference": "b97e42b95bb72d87507f5e2d137ceb239aea8d6b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Opis\\Closure\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marius Sarca",
+ "email": "marius.sarca@gmail.com"
+ },
+ {
+ "name": "Sorin Sarca",
+ "email": "sarca_sorin@hotmail.com"
+ }
+ ],
+ "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary data.",
+ "homepage": "https://opis.io/closure",
+ "keywords": [
+ "anonymous classes",
+ "anonymous functions",
+ "closure",
+ "function",
+ "serializable",
+ "serialization",
+ "serialize"
+ ],
+ "support": {
+ "issues": "https://github.com/opis/closure/issues",
+ "source": "https://github.com/opis/closure/tree/4.5.0"
+ },
+ "time": "2026-03-05T13:32:42+00:00"
+ },
{
"name": "php-http/discovery",
"version": "1.20.0",
@@ -1240,20 +1303,20 @@
},
{
"name": "ramsey/uuid",
- "version": "4.9.3",
+ "version": "4.9.2",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
- "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8"
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8",
- "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0",
"shasum": ""
},
"require": {
- "brick/math": ">=0.8.16 <=0.18",
+ "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -1312,22 +1375,22 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.9.3"
+ "source": "https://github.com/ramsey/uuid/tree/4.9.2"
},
- "time": "2026-06-18T03:57:49+00:00"
+ "time": "2025-12-14T04:43:48+00:00"
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.7.1",
+ "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
- "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
@@ -1340,7 +1403,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.7-dev"
+ "dev-main": "3.6-dev"
}
},
"autoload": {
@@ -1365,7 +1428,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
},
"funding": [
{
@@ -1376,29 +1439,25 @@
"url": "https://github.com/fabpot",
"type": "github"
},
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2026-06-05T06:23:12+00:00"
+ "time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/http-client",
- "version": "v7.4.16",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41"
+ "reference": "01933e626c3de76bea1e22641e205e78f6a34342"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/c513ed0ba5d1784a6b55fc84190dbe4451b12f41",
- "reference": "c513ed0ba5d1784a6b55fc84190dbe4451b12f41",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342",
+ "reference": "01933e626c3de76bea1e22641e205e78f6a34342",
"shasum": ""
},
"require": {
@@ -1466,7 +1525,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.16"
+ "source": "https://github.com/symfony/http-client/tree/v7.4.8"
},
"funding": [
{
@@ -1486,20 +1545,20 @@
"type": "tidelift"
}
],
- "time": "2026-07-29T16:20:51+00:00"
+ "time": "2026-03-30T12:55:43+00:00"
},
{
"name": "symfony/http-client-contracts",
- "version": "v3.7.1",
+ "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "41fc42d276aeff21192465331ebbab7d83a743c0"
+ "reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0",
- "reference": "41fc42d276aeff21192465331ebbab7d83a743c0",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
+ "reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
@@ -1512,7 +1571,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.7-dev"
+ "dev-main": "3.6-dev"
}
},
"autoload": {
@@ -1548,7 +1607,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1"
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
@@ -1559,29 +1618,25 @@
"url": "https://github.com/fabpot",
"type": "github"
},
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2026-06-05T06:23:12+00:00"
+ "time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.38.2",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
- "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
"shasum": ""
},
"require": {
@@ -1633,7 +1688,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
},
"funding": [
{
@@ -1653,20 +1708,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-27T06:59:30+00:00"
+ "time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/polyfill-php82",
- "version": "v1.38.1",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
- "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b"
+ "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
- "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
+ "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59",
+ "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59",
"shasum": ""
},
"require": {
@@ -1713,7 +1768,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1"
+ "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0"
},
"funding": [
{
@@ -1733,20 +1788,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-26T12:45:58+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.41.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
- "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6"
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6",
- "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149",
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149",
"shasum": ""
},
"require": {
@@ -1793,7 +1848,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0"
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
},
"funding": [
{
@@ -1813,20 +1868,20 @@
"type": "tidelift"
}
],
- "time": "2026-07-01T12:47:55+00:00"
+ "time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/polyfill-php85",
- "version": "v1.41.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
- "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a"
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a",
- "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee",
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"shasum": ""
},
"require": {
@@ -1873,7 +1928,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0"
},
"funding": [
{
@@ -1893,20 +1948,20 @@
"type": "tidelift"
}
],
- "time": "2026-07-01T12:47:55+00:00"
+ "time": "2026-04-26T13:10:57+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.7.1",
+ "version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
- "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
"shasum": ""
},
"require": {
@@ -1924,7 +1979,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.7-dev"
+ "dev-main": "3.6-dev"
}
},
"autoload": {
@@ -1960,7 +2015,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
},
"funding": [
{
@@ -1980,7 +2035,7 @@
"type": "tidelift"
}
],
- "time": "2026-06-16T09:55:08+00:00"
+ "time": "2025-07-15T11:30:57+00:00"
},
{
"name": "tbachert/spi",
@@ -2034,24 +2089,142 @@
},
"time": "2025-06-29T15:42:06+00:00"
},
+ {
+ "name": "utopia-php/async",
+ "version": "0.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/async.git",
+ "reference": "3ee4fc3d505113d0d6050f35f5bf85e706866a22"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/async/zipball/3ee4fc3d505113d0d6050f35f5bf85e706866a22",
+ "reference": "3ee4fc3d505113d0d6050f35f5bf85e706866a22",
+ "shasum": ""
+ },
+ "require": {
+ "opis/closure": "4.*",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "amphp/amp": "3.*",
+ "amphp/parallel": "2.*",
+ "amphp/process": "^2.0",
+ "laravel/pint": "1.*",
+ "phpstan/phpstan": "2.*",
+ "phpunit/phpunit": "11.5.45",
+ "react/child-process": "0.*",
+ "react/event-loop": "1.*",
+ "swoole/ide-helper": "*"
+ },
+ "suggest": {
+ "amphp/amp": "Required for Amp promise adapter",
+ "amphp/parallel": "Required for Amp parallel adapter",
+ "ext-ev": "Required for ReactPHP event loop (recommended for best performance)",
+ "ext-parallel": "Required for parallel adapter (requires PHP ZTS build)",
+ "ext-sockets": "Required for Swoole Process adapter",
+ "ext-swoole": "Required for Swoole Thread and Process adapters (recommended for best performance)",
+ "react/child-process": "Required for ReactPHP parallel adapter",
+ "react/event-loop": "Required for ReactPHP promise and parallel adapters"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Async\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Utopia\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test-unit": [
+ "vendor/bin/phpunit tests/Unit --exclude-group no-swoole"
+ ],
+ "test-promise-sync": [
+ "vendor/bin/phpunit tests/E2e/Promise/SyncTest.php"
+ ],
+ "test-promise-swoole": [
+ "vendor/bin/phpunit tests/E2e/Promise/Swoole"
+ ],
+ "test-promise-amp": [
+ "vendor/bin/phpunit tests/E2e/Promise/Amp"
+ ],
+ "test-promise-react": [
+ "vendor/bin/phpunit tests/E2e/Promise/React"
+ ],
+ "test-parallel-sync": [
+ "vendor/bin/phpunit tests/E2e/Parallel/Sync"
+ ],
+ "test-parallel-swoole-thread": [
+ "vendor/bin/phpunit tests/E2e/Parallel/Swoole/ThreadTest.php"
+ ],
+ "test-parallel-swoole-process": [
+ "vendor/bin/phpunit tests/E2e/Parallel/Swoole/ProcessTest.php"
+ ],
+ "test-parallel-amp": [
+ "vendor/bin/phpunit tests/E2e/Parallel/Amp"
+ ],
+ "test-parallel-react": [
+ "vendor/bin/phpunit tests/E2e/Parallel/React"
+ ],
+ "test-parallel-ext": [
+ "php -n -d extension=parallel.so -d extension=sockets.so vendor/bin/phpunit tests/E2e/Parallel/Parallel"
+ ],
+ "test-e2e": [
+ "vendor/bin/phpunit tests/E2e --exclude-group ext-parallel"
+ ],
+ "test": [
+ "@test-unit",
+ "@test-e2e",
+ "@test-parallel-ext"
+ ],
+ "lint": [
+ "vendor/bin/pint"
+ ],
+ "format": [
+ "php -d memory_limit=4G vendor/bin/pint"
+ ],
+ "check": [
+ "vendor/bin/phpstan analyse src tests --level=max --memory-limit=4G"
+ ]
+ },
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Appwrite Team",
+ "email": "team@appwrite.io"
+ }
+ ],
+ "description": "High-performance concurrent + parallel library with Promise and Parallel execution support for PHP.",
+ "support": {
+ "source": "https://github.com/utopia-php/async/tree/0.1.1",
+ "issues": "https://github.com/utopia-php/async/issues"
+ },
+ "time": "2026-06-08T05:10:34+00:00"
+ },
{
"name": "utopia-php/cache",
- "version": "5.0.0",
+ "version": "4.0.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/cache.git",
- "reference": "0d0752785fc81b5afd6571f4291763166a6532bc"
+ "reference": "92e02dab63606234b993b841ebf4c58845dd4620"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/cache/zipball/0d0752785fc81b5afd6571f4291763166a6532bc",
- "reference": "0d0752785fc81b5afd6571f4291763166a6532bc",
+ "url": "https://api.github.com/repos/utopia-php/cache/zipball/92e02dab63606234b993b841ebf4c58845dd4620",
+ "reference": "92e02dab63606234b993b841ebf4c58845dd4620",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=8.4",
- "utopia-php/circuit-breaker": "^0.4",
+ "utopia-php/circuit-breaker": "^0.3",
"utopia-php/pools": "^2.0",
"utopia-php/telemetry": "^0.4"
},
@@ -2089,22 +2262,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/cache/issues",
- "source": "https://github.com/utopia-php/cache/tree/5.0.0"
+ "source": "https://github.com/utopia-php/cache/tree/4.0.2"
},
- "time": "2026-08-21T11:04:48+00:00"
+ "time": "2026-08-12T07:48:59+00:00"
},
{
"name": "utopia-php/circuit-breaker",
- "version": "0.4.0",
+ "version": "0.3.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/circuit-breaker.git",
- "reference": "c6d93c7ba9d895cf906360e000f74ff762d83e17"
+ "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/c6d93c7ba9d895cf906360e000f74ff762d83e17",
- "reference": "c6d93c7ba9d895cf906360e000f74ff762d83e17",
+ "url": "https://api.github.com/repos/utopia-php/circuit-breaker/zipball/5fbc3802471b0d1b4260bd9f5544514e6929b481",
+ "reference": "5fbc3802471b0d1b4260bd9f5544514e6929b481",
"shasum": ""
},
"require": {
@@ -2148,9 +2321,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/circuit-breaker/issues",
- "source": "https://github.com/utopia-php/circuit-breaker/tree/0.4.0"
+ "source": "https://github.com/utopia-php/circuit-breaker/tree/0.3.2"
},
- "time": "2026-08-21T10:20:24+00:00"
+ "time": "2026-08-05T18:07:20+00:00"
},
{
"name": "utopia-php/console",
@@ -2317,6 +2490,86 @@
},
"time": "2026-08-05T18:07:20+00:00"
},
+ {
+ "name": "utopia-php/query",
+ "version": "0.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/query.git",
+ "reference": "abaebb2f3426bdbc6f44bab04254a668de937148"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/query/zipball/abaebb2f3426bdbc6f44bab04254a668de937148",
+ "reference": "abaebb2f3426bdbc6f44bab04254a668de937148",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "brianium/paratest": "*",
+ "laravel/pint": "*",
+ "mongodb/mongodb": "^2.0",
+ "phpstan/phpstan": "*",
+ "phpunit/phpcov": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Query\\": "src/Query"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\Query\\": "tests/Query",
+ "Tests\\Integration\\": "tests/Integration"
+ }
+ },
+ "scripts": {
+ "test": [
+ "vendor/bin/paratest --testsuite Query --processes=auto --exclude-group=performance"
+ ],
+ "test:coverage": [
+ "vendor/bin/paratest --testsuite Query --processes=auto --exclude-group=performance --coverage-php coverage/unit.cov"
+ ],
+ "test:performance": [
+ "vendor/bin/phpunit --testsuite Query --group=performance"
+ ],
+ "test:integration": [
+ "vendor/bin/phpunit --testsuite Integration"
+ ],
+ "test:integration:coverage": [
+ "vendor/bin/phpunit --testsuite Integration --coverage-php coverage/integration.cov"
+ ],
+ "lint": [
+ "php -d memory_limit=2G ./vendor/bin/pint --test"
+ ],
+ "format": [
+ "php -d memory_limit=2G ./vendor/bin/pint"
+ ],
+ "check": [
+ "./vendor/bin/phpstan analyse --level max src tests --memory-limit 2G"
+ ]
+ },
+ "license": [
+ "MIT"
+ ],
+ "description": "A simple library providing a query abstraction for filtering, ordering, and pagination",
+ "keywords": [
+ "framework",
+ "php",
+ "query",
+ "upf",
+ "utopia"
+ ],
+ "support": {
+ "source": "https://github.com/utopia-php/query/tree/0.6.0",
+ "issues": "https://github.com/utopia-php/query/issues"
+ },
+ "time": "2026-08-21T11:03:36+00:00"
+ },
{
"name": "utopia-php/telemetry",
"version": "0.4.6",
@@ -2411,35 +2664,56 @@
],
"packages-dev": [
{
- "name": "doctrine/instantiator",
- "version": "2.1.0",
+ "name": "brianium/paratest",
+ "version": "v7.20.0",
"source": {
"type": "git",
- "url": "https://github.com/doctrine/instantiator.git",
- "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7"
+ "url": "https://github.com/paratestphp/paratest.git",
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7",
- "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"shasum": ""
},
"require": {
- "php": "^8.4"
+ "ext-dom": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-simplexml": "*",
+ "fidry/cpu-core-counter": "^1.3.0",
+ "jean85/pretty-package-versions": "^2.1.1",
+ "php": "~8.3.0 || ~8.4.0 || ~8.5.0",
+ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
+ "phpunit/php-file-iterator": "^6.0.1 || ^7",
+ "phpunit/php-timer": "^8 || ^9",
+ "phpunit/phpunit": "^12.5.14 || ^13.0.5",
+ "sebastian/environment": "^8.0.3 || ^9",
+ "symfony/console": "^7.4.7 || ^8.0.7",
+ "symfony/process": "^7.4.5 || ^8.0.5"
},
"require-dev": {
- "doctrine/coding-standard": "^14",
- "ext-pdo": "*",
- "ext-phar": "*",
- "phpbench/phpbench": "^1.2",
- "phpstan/phpstan": "^2.1",
- "phpstan/phpstan-phpunit": "^2.0",
- "phpunit/phpunit": "^10.5.58"
+ "doctrine/coding-standard": "^14.0.0",
+ "ext-pcntl": "*",
+ "ext-pcov": "*",
+ "ext-posix": "*",
+ "phpstan/phpstan": "^2.1.44",
+ "phpstan/phpstan-deprecation-rules": "^2.0.4",
+ "phpstan/phpstan-phpunit": "^2.0.16",
+ "phpstan/phpstan-strict-rules": "^2.0.10",
+ "symfony/filesystem": "^7.4.6 || ^8.0.6"
},
+ "bin": [
+ "bin/paratest",
+ "bin/paratest_for_phpstorm"
+ ],
"type": "library",
"autoload": {
"psr-4": {
- "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ "ParaTest\\": [
+ "src/"
+ ]
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -2448,36 +2722,39 @@
],
"authors": [
{
- "name": "Marco Pivetta",
- "email": "ocramius@gmail.com",
- "homepage": "https://ocramius.github.io/"
+ "name": "Brian Scaturro",
+ "email": "scaturrob@gmail.com",
+ "role": "Developer"
+ },
+ {
+ "name": "Filippo Tessarotto",
+ "email": "zoeslam@gmail.com",
+ "role": "Developer"
}
],
- "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
- "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "description": "Parallel testing for PHP",
+ "homepage": "https://github.com/paratestphp/paratest",
"keywords": [
- "constructor",
- "instantiate"
+ "concurrent",
+ "parallel",
+ "phpunit",
+ "testing"
],
"support": {
- "issues": "https://github.com/doctrine/instantiator/issues",
- "source": "https://github.com/doctrine/instantiator/tree/2.1.0"
+ "issues": "https://github.com/paratestphp/paratest/issues",
+ "source": "https://github.com/paratestphp/paratest/tree/v7.20.0"
},
"funding": [
{
- "url": "https://www.doctrine-project.org/sponsorship.html",
- "type": "custom"
- },
- {
- "url": "https://www.patreon.com/phpdoctrine",
- "type": "patreon"
+ "url": "https://github.com/sponsors/Slamdunk",
+ "type": "github"
},
{
- "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
- "type": "tidelift"
+ "url": "https://paypal.me/filippotessarotto",
+ "type": "paypal"
}
],
- "time": "2026-01-05T06:47:08+00:00"
+ "time": "2026-03-29T15:46:14+00:00"
},
{
"name": "fakerphp/faker",
@@ -2542,18 +2819,139 @@
},
"time": "2024-01-02T13:46:09+00:00"
},
+ {
+ "name": "fidry/cpu-core-counter",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/cpu-core-counter.git",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "fidry/makefile": "^0.2.0",
+ "fidry/php-cs-fixer-config": "^1.1.2",
+ "phpstan/extension-installer": "^1.2.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "webmozarts/strict-phpunit": "^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Fidry\\CpuCoreCounter\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Théo FIDRY",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Tiny utility to get the number of CPU cores.",
+ "keywords": [
+ "CPU",
+ "core"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-14T07:29:31+00:00"
+ },
+ {
+ "name": "jean85/pretty-package-versions",
+ "version": "2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Jean85/pretty-package-versions.git",
+ "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
+ "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
+ "shasum": ""
+ },
+ "require": {
+ "composer-runtime-api": "^2.1.0",
+ "php": "^7.4|^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.2",
+ "jean85/composer-provided-replaced-stub-package": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^7.5|^8.5|^9.6",
+ "rector/rector": "^2.0",
+ "vimeo/psalm": "^4.3 || ^5.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Jean85\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Alessandro Lai",
+ "email": "alessandro.lai85@gmail.com"
+ }
+ ],
+ "description": "A library to get pretty versions strings of installed dependencies",
+ "keywords": [
+ "composer",
+ "package",
+ "release",
+ "versions"
+ ],
+ "support": {
+ "issues": "https://github.com/Jean85/pretty-package-versions/issues",
+ "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
+ },
+ "time": "2025-03-19T14:43:43+00:00"
+ },
{
"name": "laravel/pint",
- "version": "v1.30.4",
+ "version": "v1.29.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28"
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28",
- "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": ""
},
"require": {
@@ -2564,16 +2962,14 @@
"php": "^8.2.0"
},
"require-dev": {
- "composer/semver": "^3.4.4",
- "friendsofphp/php-cs-fixer": "^3.95.18",
- "illuminate/view": "^12.65.0",
- "larastan/larastan": "^3.10.0",
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
"laravel-zero/framework": "^12.1.0",
- "laravel/agent-detector": "^2.0.2",
- "laravel/prompts": "^0.3.22",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.4.0",
- "pestphp/pest": "^3.8.7"
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.3"
},
"bin": [
"builds/pint"
@@ -2610,7 +3006,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2026-08-05T16:47:22+00:00"
+ "time": "2026-04-20T15:26:14+00:00"
},
{
"name": "myclabs/deep-copy",
@@ -2674,30 +3070,37 @@
},
{
"name": "nikic/php-parser",
- "version": "v4.19.5",
+ "version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837"
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/51bd93cc741b7fc3d63d20b6bdcd99fdaa359837",
- "reference": "51bd93cc741b7fc3d63d20b6bdcd99fdaa359837",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
"ext-tokenizer": "*",
- "php": ">=7.1"
+ "php": ">=7.4"
},
"require-dev": {
"ircmaxell/php-yacc": "^0.0.7",
- "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
+ "phpunit/phpunit": "^9.0"
},
"bin": [
"bin/php-parse"
],
"type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
"autoload": {
"psr-4": {
"PhpParser\\": "lib/PhpParser"
@@ -2719,56 +3122,22 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.5"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
- "time": "2025-12-06T11:45:25+00:00"
+ "time": "2025-12-06T11:56:16+00:00"
},
{
- "name": "pcov/clobber",
- "version": "v2.0.3",
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
"source": {
"type": "git",
- "url": "https://github.com/krakjoe/pcov-clobber.git",
- "reference": "4c30759e912e6e5d5bf833fb3d77b5bd51709f05"
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/krakjoe/pcov-clobber/zipball/4c30759e912e6e5d5bf833fb3d77b5bd51709f05",
- "reference": "4c30759e912e6e5d5bf833fb3d77b5bd51709f05",
- "shasum": ""
- },
- "require": {
- "ext-pcov": "^1.0",
- "nikic/php-parser": "^4.2"
- },
- "bin": [
- "bin/pcov"
- ],
- "type": "library",
- "autoload": {
- "psr-4": {
- "pcov\\Clobber\\": "src/pcov/clobber"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "support": {
- "issues": "https://github.com/krakjoe/pcov-clobber/issues",
- "source": "https://github.com/krakjoe/pcov-clobber/tree/v2.0.3"
- },
- "time": "2019-10-29T05:03:37+00:00"
- },
- {
- "name": "phar-io/manifest",
- "version": "2.0.4",
- "source": {
- "type": "git",
- "url": "https://github.com/phar-io/manifest.git",
- "reference": "54750ef60c58e43759730615a392c31c80e23176"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
- "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
"shasum": ""
},
"require": {
@@ -2877,15 +3246,15 @@
},
{
"name": "phpstan/phpstan",
- "version": "1.12.34",
+ "version": "2.1.54",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4dd89ca7aa30fdc6760be21550d583bcc32e8476",
- "reference": "4dd89ca7aa30fdc6760be21550d583bcc32e8476",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
+ "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"shasum": ""
},
"require": {
- "php": "^7.2|^8.0"
+ "php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
@@ -2926,39 +3295,93 @@
"type": "github"
}
],
- "time": "2026-07-28T10:04:39+00:00"
+ "time": "2026-04-29T13:31:09+00:00"
+ },
+ {
+ "name": "phpstan/phpstan-phpunit",
+ "version": "2.0.16",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpstan-phpunit.git",
+ "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
+ "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "phpstan/phpstan": "^2.1.32"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<7.0"
+ },
+ "require-dev": {
+ "nikic/php-parser": "^5",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon",
+ "rules.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPUnit extensions and rules for PHPStan",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpstan/phpstan-phpunit/issues",
+ "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16"
+ },
+ "time": "2026-02-14T09:05:21+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "9.2.32",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
- "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"ext-xmlwriter": "*",
- "nikic/php-parser": "^4.19.1 || ^5.1.0",
- "php": ">=7.3",
- "phpunit/php-file-iterator": "^3.0.6",
- "phpunit/php-text-template": "^2.0.4",
- "sebastian/code-unit-reverse-lookup": "^2.0.3",
- "sebastian/complexity": "^2.0.3",
- "sebastian/environment": "^5.1.5",
- "sebastian/lines-of-code": "^1.0.4",
- "sebastian/version": "^3.0.2",
- "theseer/tokenizer": "^1.2.3"
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3",
+ "phpunit/php-text-template": "^5.0",
+ "sebastian/complexity": "^5.0",
+ "sebastian/environment": "^8.0.3",
+ "sebastian/lines-of-code": "^4.0",
+ "sebastian/version": "^6.0",
+ "theseer/tokenizer": "^2.0.1"
},
"require-dev": {
- "phpunit/phpunit": "^9.6"
+ "phpunit/phpunit": "^12.5.1"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -2967,7 +3390,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "9.2.x-dev"
+ "dev-main": "12.5.x-dev"
}
},
"autoload": {
@@ -2996,40 +3419,52 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
}
],
- "time": "2024-08-22T04:23:01+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
- "version": "3.0.6",
+ "version": "6.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
- "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -3056,36 +3491,49 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
- "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
}
],
- "time": "2021-12-02T12:48:52+00:00"
+ "time": "2026-02-02T14:04:18+00:00"
},
{
"name": "phpunit/php-invoker",
- "version": "3.1.1",
+ "version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-invoker.git",
- "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
- "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
"ext-pcntl": "*",
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"suggest": {
"ext-pcntl": "*"
@@ -3093,7 +3541,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.1-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -3119,7 +3567,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-invoker/issues",
- "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0"
},
"funding": [
{
@@ -3127,32 +3576,32 @@
"type": "github"
}
],
- "time": "2020-09-28T05:58:55+00:00"
+ "time": "2025-02-07T04:58:58+00:00"
},
{
"name": "phpunit/php-text-template",
- "version": "2.0.4",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-text-template.git",
- "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
- "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53",
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -3178,7 +3627,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-text-template/issues",
- "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0"
},
"funding": [
{
@@ -3186,32 +3636,32 @@
"type": "github"
}
],
- "time": "2020-10-26T05:33:50+00:00"
+ "time": "2025-02-07T04:59:16+00:00"
},
{
"name": "phpunit/php-timer",
- "version": "5.0.3",
+ "version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-timer.git",
- "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
- "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "5.0-dev"
+ "dev-main": "8.0-dev"
}
},
"autoload": {
@@ -3237,7 +3687,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/php-timer/issues",
- "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0"
},
"funding": [
{
@@ -3245,54 +3696,49 @@
"type": "github"
}
],
- "time": "2020-10-26T13:16:10+00:00"
+ "time": "2025-02-07T04:59:38+00:00"
},
{
"name": "phpunit/phpunit",
- "version": "9.6.35",
+ "version": "12.5.23",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b"
+ "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b",
- "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
+ "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969",
"shasum": ""
},
"require": {
- "doctrine/instantiator": "^1.5.0 || ^2",
"ext-dom": "*",
- "ext-filter": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
+ "ext-xml": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
- "php": ">=7.3",
- "phpunit/php-code-coverage": "^9.2.32",
- "phpunit/php-file-iterator": "^3.0.6",
- "phpunit/php-invoker": "^3.1.1",
- "phpunit/php-text-template": "^2.0.4",
- "phpunit/php-timer": "^5.0.3",
- "sebastian/cli-parser": "^1.0.2",
- "sebastian/code-unit": "^1.0.8",
- "sebastian/comparator": "^4.0.10",
- "sebastian/diff": "^4.0.6",
- "sebastian/environment": "^5.1.5",
- "sebastian/exporter": "^4.0.8",
- "sebastian/global-state": "^5.0.8",
- "sebastian/object-enumerator": "^4.0.4",
- "sebastian/resource-operations": "^3.0.4",
- "sebastian/type": "^3.2.1",
- "sebastian/version": "^3.0.2"
- },
- "suggest": {
- "ext-soap": "To be able to generate mocks based on WSDL files",
- "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ "php": ">=8.3",
+ "phpunit/php-code-coverage": "^12.5.6",
+ "phpunit/php-file-iterator": "^6.0.1",
+ "phpunit/php-invoker": "^6.0.0",
+ "phpunit/php-text-template": "^5.0.0",
+ "phpunit/php-timer": "^8.0.0",
+ "sebastian/cli-parser": "^4.2.0",
+ "sebastian/comparator": "^7.1.6",
+ "sebastian/diff": "^7.0.0",
+ "sebastian/environment": "^8.1.0",
+ "sebastian/exporter": "^7.0.2",
+ "sebastian/global-state": "^8.0.2",
+ "sebastian/object-enumerator": "^7.0.0",
+ "sebastian/recursion-context": "^7.0.1",
+ "sebastian/type": "^6.0.3",
+ "sebastian/version": "^6.0.0",
+ "staabm/side-effects-detector": "^1.0.5"
},
"bin": [
"phpunit"
@@ -3300,7 +3746,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "9.6-dev"
+ "dev-main": "12.5-dev"
}
},
"autoload": {
@@ -3332,7 +3778,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23"
},
"funding": [
{
@@ -3340,7 +3786,7 @@
"type": "other"
}
],
- "time": "2026-07-06T14:48:07+00:00"
+ "time": "2026-04-18T06:12:49+00:00"
},
{
"name": "rregeer/phpunit-coverage-check",
@@ -3390,28 +3836,28 @@
},
{
"name": "sebastian/cli-parser",
- "version": "1.0.2",
+ "version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
+ "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
- "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04",
+ "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0-dev"
+ "dev-main": "4.2-dev"
}
},
"autoload": {
@@ -3434,153 +3880,60 @@
"homepage": "https://github.com/sebastianbergmann/cli-parser",
"support": {
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
- "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
- }
- ],
- "time": "2024-03-02T06:27:43+00:00"
- },
- {
- "name": "sebastian/code-unit",
- "version": "1.0.8",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit.git",
- "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
- "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
- "shasum": ""
- },
- "require": {
- "php": ">=7.3"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.3"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Collection of value objects that represent the PHP code units",
- "homepage": "https://github.com/sebastianbergmann/code-unit",
- "support": {
- "issues": "https://github.com/sebastianbergmann/code-unit/issues",
- "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
- },
- "funding": [
+ },
{
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2020-10-26T13:08:54+00:00"
- },
- {
- "name": "sebastian/code-unit-reverse-lookup",
- "version": "2.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
- "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
- "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
- "shasum": ""
- },
- "require": {
- "php": ">=7.3"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.3"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
{
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Looks up which function or method a line of code belongs to",
- "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
- "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
- },
- "funding": [
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
{
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
}
],
- "time": "2020-09-28T05:30:19+00:00"
+ "time": "2025-09-14T09:36:45+00:00"
},
{
"name": "sebastian/comparator",
- "version": "4.0.10",
+ "version": "7.1.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
- "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e",
+ "reference": "c769009dee98f494e0edc3fd4f4087501688f11e",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/diff": "^4.0",
- "sebastian/exporter": "^4.0"
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/diff": "^7.0",
+ "sebastian/exporter": "^7.0"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.2"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.1-dev"
}
},
"autoload": {
@@ -3619,7 +3972,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
- "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6"
},
"funding": [
{
@@ -3639,33 +3993,33 @@
"type": "tidelift"
}
],
- "time": "2026-01-24T09:22:56+00:00"
+ "time": "2026-04-14T08:23:15+00:00"
},
{
"name": "sebastian/complexity",
- "version": "2.0.3",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/complexity.git",
- "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
- "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^4.18 || ^5.0",
- "php": ">=7.3"
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -3688,7 +4042,8 @@
"homepage": "https://github.com/sebastianbergmann/complexity",
"support": {
"issues": "https://github.com/sebastianbergmann/complexity/issues",
- "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0"
},
"funding": [
{
@@ -3696,33 +4051,33 @@
"type": "github"
}
],
- "time": "2023-12-22T06:19:30+00:00"
+ "time": "2025-02-07T04:55:25+00:00"
},
{
"name": "sebastian/diff",
- "version": "4.0.6",
+ "version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
- "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3",
- "symfony/process": "^4.2 || ^5"
+ "phpunit/phpunit": "^12.0",
+ "symfony/process": "^7.2"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -3754,7 +4109,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
- "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
},
"funding": [
{
@@ -3762,27 +4118,27 @@
"type": "github"
}
],
- "time": "2024-03-02T06:30:58+00:00"
+ "time": "2025-02-07T04:55:46+00:00"
},
{
"name": "sebastian/environment",
- "version": "5.1.5",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
- "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"suggest": {
"ext-posix": "*"
@@ -3790,7 +4146,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "5.1-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -3809,7 +4165,7 @@
}
],
"description": "Provides functionality to handle HHVM/PHP environments",
- "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "homepage": "https://github.com/sebastianbergmann/environment",
"keywords": [
"Xdebug",
"environment",
@@ -3817,42 +4173,55 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
- "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
}
],
- "time": "2023-02-03T06:03:51+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
- "version": "4.0.8",
+ "version": "7.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
+ "reference": "016951ae10980765e4e7aee491eb288c64e505b7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
- "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7",
+ "reference": "016951ae10980765e4e7aee491eb288c64e505b7",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/recursion-context": "^4.0"
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
- "ext-mbstring": "*",
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -3894,7 +4263,8 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
- "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2"
},
"funding": [
{
@@ -3914,38 +4284,35 @@
"type": "tidelift"
}
],
- "time": "2025-09-24T06:03:27+00:00"
+ "time": "2025-09-24T06:16:11+00:00"
},
{
"name": "sebastian/global-state",
- "version": "5.0.8",
+ "version": "8.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
- "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d",
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/object-reflector": "^2.0",
- "sebastian/recursion-context": "^4.0"
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
"ext-dom": "*",
- "phpunit/phpunit": "^9.3"
- },
- "suggest": {
- "ext-uopz": "*"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "5.0-dev"
+ "dev-main": "8.0-dev"
}
},
"autoload": {
@@ -3964,13 +4331,14 @@
}
],
"description": "Snapshotting of global state",
- "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
"keywords": [
"global state"
],
"support": {
"issues": "https://github.com/sebastianbergmann/global-state/issues",
- "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2"
},
"funding": [
{
@@ -3990,33 +4358,33 @@
"type": "tidelift"
}
],
- "time": "2025-08-10T07:10:35+00:00"
+ "time": "2025-08-29T11:29:25+00:00"
},
{
"name": "sebastian/lines-of-code",
- "version": "1.0.4",
+ "version": "4.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
+ "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
- "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f",
+ "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^4.18 || ^5.0",
- "php": ">=7.3"
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.0-dev"
+ "dev-main": "4.0-dev"
}
},
"autoload": {
@@ -4039,7 +4407,8 @@
"homepage": "https://github.com/sebastianbergmann/lines-of-code",
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
- "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0"
},
"funding": [
{
@@ -4047,34 +4416,34 @@
"type": "github"
}
],
- "time": "2023-12-22T06:20:34+00:00"
+ "time": "2025-02-07T04:57:28+00:00"
},
{
"name": "sebastian/object-enumerator",
- "version": "4.0.4",
+ "version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-enumerator.git",
- "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
- "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
"shasum": ""
},
"require": {
- "php": ">=7.3",
- "sebastian/object-reflector": "^2.0",
- "sebastian/recursion-context": "^4.0"
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -4096,7 +4465,8 @@
"homepage": "https://github.com/sebastianbergmann/object-enumerator/",
"support": {
"issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
- "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0"
},
"funding": [
{
@@ -4104,32 +4474,32 @@
"type": "github"
}
],
- "time": "2020-10-26T13:12:34+00:00"
+ "time": "2025-02-07T04:57:48+00:00"
},
{
"name": "sebastian/object-reflector",
- "version": "2.0.4",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-reflector.git",
- "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
- "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "2.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -4151,7 +4521,8 @@
"homepage": "https://github.com/sebastianbergmann/object-reflector/",
"support": {
"issues": "https://github.com/sebastianbergmann/object-reflector/issues",
- "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0"
},
"funding": [
{
@@ -4159,32 +4530,32 @@
"type": "github"
}
],
- "time": "2020-10-26T13:14:26+00:00"
+ "time": "2025-02-07T04:58:17+00:00"
},
{
"name": "sebastian/recursion-context",
- "version": "4.0.6",
+ "version": "7.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
- "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.3"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -4214,7 +4585,8 @@
"homepage": "https://github.com/sebastianbergmann/recursion-context",
"support": {
"issues": "https://github.com/sebastianbergmann/recursion-context/issues",
- "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1"
},
"funding": [
{
@@ -4234,32 +4606,32 @@
"type": "tidelift"
}
],
- "time": "2025-08-10T06:57:39+00:00"
+ "time": "2025-08-13T04:44:59+00:00"
},
{
- "name": "sebastian/resource-operations",
- "version": "3.0.4",
+ "name": "sebastian/type",
+ "version": "6.0.3",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/resource-operations.git",
- "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
- "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d",
+ "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^9.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "3.0-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -4274,46 +4646,58 @@
"authors": [
{
"name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
}
],
- "description": "Provides a list of PHP built-in functions that operate on resources",
- "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
"support": {
- "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.3"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
}
],
- "time": "2024-03-14T16:00:52+00:00"
+ "time": "2025-08-09T06:57:12+00:00"
},
{
- "name": "sebastian/type",
- "version": "3.2.1",
+ "name": "sebastian/version",
+ "version": "6.0.0",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/type.git",
- "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
- "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c",
"shasum": ""
},
"require": {
- "php": ">=7.3"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.5"
+ "php": ">=8.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.2-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -4332,11 +4716,12 @@
"role": "lead"
}
],
- "description": "Collection of value objects that represent the types of the PHP type system",
- "homepage": "https://github.com/sebastianbergmann/type",
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
"support": {
- "issues": "https://github.com/sebastianbergmann/type/issues",
- "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/6.0.0"
},
"funding": [
{
@@ -4344,60 +4729,59 @@
"type": "github"
}
],
- "time": "2023-02-03T06:13:03+00:00"
+ "time": "2025-02-07T05:00:38+00:00"
},
{
- "name": "sebastian/version",
- "version": "3.0.2",
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
"source": {
"type": "git",
- "url": "https://github.com/sebastianbergmann/version.git",
- "reference": "c6c1022351a901512170118436c764e473f6de8c"
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
- "reference": "c6c1022351a901512170118436c764e473f6de8c",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
"shasum": ""
},
"require": {
- "php": ">=7.3"
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
},
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.0-dev"
- }
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
},
+ "type": "library",
"autoload": {
"classmap": [
- "src/"
+ "lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "BSD-3-Clause"
+ "MIT"
],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
],
- "description": "Library that helps with managing the version number of Git-hosted PHP projects",
- "homepage": "https://github.com/sebastianbergmann/version",
"support": {
- "issues": "https://github.com/sebastianbergmann/version/issues",
- "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
},
"funding": [
{
- "url": "https://github.com/sebastianbergmann",
+ "url": "https://github.com/staabm",
"type": "github"
}
],
- "time": "2020-09-28T06:39:44+00:00"
+ "time": "2024-10-20T05:08:20+00:00"
},
{
"name": "swoole/ide-helper",
@@ -4432,24 +4816,519 @@
"time": "2024-06-17T05:45:20+00:00"
},
{
- "name": "theseer/tokenizer",
- "version": "1.3.1",
+ "name": "symfony/console",
+ "version": "v8.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7",
+ "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/polyfill-mbstring": "^1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^7.4|^8.0"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/event-dispatcher": "^7.4|^8.0",
+ "symfony/http-foundation": "^7.4|^8.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/lock": "^7.4|^8.0",
+ "symfony/messenger": "^7.4|^8.0",
+ "symfony/process": "^7.4|^8.0",
+ "symfony/stopwatch": "^7.4|^8.0",
+ "symfony/var-dumper": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v8.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T15:14:47+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-26T13:13:48+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/process",
+ "version": "v8.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/process.git",
+ "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
+ "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Process\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Executes commands in sub-processes",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v8.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T15:14:47+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v8.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "ae9488f874d7603f9d2dfbf120203882b645d963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963",
+ "reference": "ae9488f874d7603f9d2dfbf120203882b645d963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/polyfill-ctype": "^1.8",
+ "symfony/polyfill-intl-grapheme": "^1.33",
+ "symfony/polyfill-intl-normalizer": "^1.0",
+ "symfony/polyfill-mbstring": "^1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/emoji": "^7.4|^8.0",
+ "symfony/http-client": "^7.4|^8.0",
+ "symfony/intl": "^7.4|^8.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v8.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T15:14:47+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
- "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
- "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-tokenizer": "*",
"ext-xmlwriter": "*",
- "php": "^7.2 || ^8.0"
+ "php": "^8.1"
},
"type": "library",
"autoload": {
@@ -4471,7 +5350,7 @@
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
"support": {
"issues": "https://github.com/theseer/tokenizer/issues",
- "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
},
"funding": [
{
@@ -4479,7 +5358,7 @@
"type": "github"
}
],
- "time": "2025-11-17T20:03:58+00:00"
+ "time": "2025-12-08T11:19:18+00:00"
},
{
"name": "utopia-php/cli",
@@ -4636,9 +5515,9 @@
}
],
"aliases": [],
- "minimum-stability": "stable",
+ "minimum-stability": "dev",
"stability-flags": {},
- "prefer-stable": false,
+ "prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": ">=8.5",
diff --git a/docker-compose.yml b/docker-compose.yml
index bbd6976e5f..10b3c036c0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,6 +4,7 @@ services:
image: databases-dev
build:
context: .
+ dockerfile: Dockerfile
args:
DEBUG: true
networks:
@@ -19,29 +20,11 @@ services:
- ./docker-compose.yml:/usr/src/code/docker-compose.yml
environment:
PHP_IDE_CONFIG: serverName=tests
- depends_on:
- postgres:
- condition: service_healthy
- postgres-mirror:
- condition: service_healthy
- mariadb:
- condition: service_healthy
- mariadb-mirror:
- condition: service_healthy
- mysql:
- condition: service_healthy
- mysql-mirror:
- condition: service_healthy
- redis:
- condition: service_healthy
- redis-mirror:
- condition: service_healthy
- mongo:
- condition: service_healthy
adminer:
image: adminer
container_name: utopia-adminer
+ profiles: [debug]
restart: always
ports:
- "8700:8080"
@@ -55,6 +38,7 @@ services:
args:
POSTGRES_VERSION: 16
container_name: utopia-postgres
+ profiles: [postgres]
networks:
- database
ports:
@@ -77,6 +61,7 @@ services:
args:
POSTGRES_VERSION: 16
container_name: utopia-postgres-mirror
+ profiles: [postgres-mirror]
networks:
- database
ports:
@@ -95,6 +80,7 @@ services:
mariadb:
image: mariadb:10.11
container_name: utopia-mariadb
+ profiles: [mariadb]
command: mariadbd --max_allowed_packet=1G
networks:
- database
@@ -112,6 +98,7 @@ services:
mariadb-mirror:
image: mariadb:10.11
container_name: utopia-mariadb-mirror
+ profiles: [mariadb-mirror]
command: mariadbd --max_allowed_packet=1G
networks:
- database
@@ -129,6 +116,7 @@ services:
mongo:
image: mongo:8.0.14
container_name: utopia-mongo
+ profiles: [mongo]
entrypoint: ["/entrypoint.sh"]
networks:
- database
@@ -161,6 +149,7 @@ services:
mongo-express:
image: mongo-express
container_name: mongo-express
+ profiles: [debug]
depends_on:
mongo:
condition: service_healthy
@@ -176,6 +165,7 @@ services:
mysql:
image: mysql:8.0.43
container_name: utopia-mysql
+ profiles: [mysql]
networks:
- database
ports:
@@ -198,6 +188,7 @@ services:
mysql-mirror:
image: mysql:8.0.43
container_name: utopia-mysql-mirror
+ profiles: [mysql-mirror]
networks:
- database
ports:
@@ -220,6 +211,7 @@ services:
redis:
image: redis:8.2.1-alpine3.22
container_name: utopia-redis
+ restart: always
ports:
- "8708:6379"
networks:
@@ -234,6 +226,8 @@ services:
redis-mirror:
image: redis:8.2.1-alpine3.22
container_name: utopia-redis-mirror
+ profiles: [redis-mirror]
+ restart: always
ports:
- "8709:6379"
networks:
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000000..c8e20bf4af
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,16 @@
+includes:
+ - vendor/phpstan/phpstan-phpunit/extension.neon
+
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ scanFiles:
+ - stubs/Swoole/Database/DetectsLostConnections.stub.php
+ - stubs/Swoole/Database/PDOProxy.stub.php
+ - stubs/Swoole/Database/PDOStatementProxy.stub.php
+ stubFiles:
+ - stubs/Swoole/Database/DetectsLostConnections.stub.php
+ - stubs/Swoole/Database/PDOProxy.stub.php
+ - stubs/Swoole/Database/PDOStatementProxy.stub.php
diff --git a/phpunit.xml b/phpunit.xml
index 2a0531cfd0..fa32485526 100755
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,13 +1,13 @@
-
@@ -17,4 +17,4 @@
./tests/e2e/Adapter
-
\ No newline at end of file
+
diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php
index 4d2f0ee38f..0ba0b330ac 100644
--- a/src/Database/Adapter.php
+++ b/src/Database/Adapter.php
@@ -2,7 +2,10 @@
namespace Utopia\Database;
+use DateTime;
use Exception;
+use Throwable;
+use Utopia\Database\Adapter\Feature;
use Utopia\Database\Exception as DatabaseException;
use Utopia\Database\Exception\Authorization as AuthorizationException;
use Utopia\Database\Exception\Conflict as ConflictException;
@@ -12,11 +15,20 @@
use Utopia\Database\Exception\Restricted as RestrictedException;
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Exception\Transaction as TransactionException;
+use Utopia\Database\Hook\Transform;
+use Utopia\Database\Hook\Write;
+use Utopia\Database\Profiler\QueryProfiler;
use Utopia\Database\Validator\Authorization;
+use Utopia\Query\CursorDirection;
+use Utopia\Query\Method;
-abstract class Adapter
+/**
+ * Abstract base class for all database adapters, providing shared state management and a contract for database operations.
+ */
+abstract class Adapter implements Feature\Attributes, Feature\Collections, Feature\Databases, Feature\Documents, Feature\Indexes, Feature\Transactions
{
protected string $database = '';
+
protected string $hostname = '';
protected string $namespace = '';
@@ -29,6 +41,11 @@ abstract class Adapter
protected int $timeout = 0;
+ /**
+ * @var array
+ */
+ protected array $timeouts = [];
+
protected int $inTransaction = 0;
protected bool $alterLocks = false;
@@ -41,11 +58,9 @@ abstract class Adapter
protected array $debug = [];
/**
- * @var array>
+ * @var array
*/
- protected array $transformations = [
- '*' => [],
- ];
+ protected array $queryTransforms = [];
/**
* @var array
@@ -53,13 +68,60 @@ abstract class Adapter
protected array $metadata = [];
/**
- * @var Authorization
+ * @var list
*/
+ protected array $writeHooks = [];
+
+ protected ?QueryProfiler $profiler = null;
+
protected Authorization $authorization;
+ /** @var array|null */
+ protected ?array $capabilitySet = null;
+
+ /**
+ * Check if this adapter supports a given capability.
+ *
+ * @param Capability $feature Capability enum case
+ */
+ public function supports(Capability $feature): bool
+ {
+ if ($this->capabilitySet === null) {
+ $this->capabilitySet = [];
+ foreach ($this->capabilities() as $cap) {
+ $this->capabilitySet[$cap->name] = true;
+ }
+ }
+ return isset($this->capabilitySet[$feature->name]);
+ }
+
+ /**
+ * @template T of object
+ *
+ * @param class-string $feature
+ *
+ * @phpstan-assert-if-true T $this
+ */
+ public function hasFeature(string $feature): bool
+ {
+ return $this instanceof $feature;
+ }
+
/**
- * @param Authorization $authorization
+ * Get the list of capabilities this adapter supports.
*
+ * @return array
+ */
+ public function capabilities(): array
+ {
+ return [
+ Capability::Index,
+ Capability::IndexArray,
+ Capability::UniqueIndex,
+ ];
+ }
+
+ /**
* @return $this
*/
public function setAuthorization(Authorization $authorization): self
@@ -69,39 +131,51 @@ public function setAuthorization(Authorization $authorization): self
return $this;
}
+ /**
+ * Get the authorization instance used for permission checks.
+ *
+ * @return Authorization The current authorization instance.
+ */
public function getAuthorization(): Authorization
{
return $this->authorization;
}
- /**
- * @param string $key
- * @param mixed $value
- *
- * @return $this
- */
- public function setDebug(string $key, mixed $value): static
+
+ public function setProfiler(?QueryProfiler $profiler): static
{
- $this->debug[$key] = $value;
+ $this->profiler = $profiler;
return $this;
}
+ public function getProfiler(): ?QueryProfiler
+ {
+ return $this->profiler;
+ }
+
/**
- * @return array
+ * Set Database.
+ *
+ * Set database to use for current scope
+ *
+ *
+ * @throws DatabaseException
*/
- public function getDebug(): array
+ public function setDatabase(string $name): bool
{
- return $this->debug;
+ $this->database = $this->filter($name);
+
+ return true;
}
/**
- * @return static
+ * Get Database.
+ *
+ * Get Database from current scope
*/
- public function resetDebug(): static
+ public function getDatabase(): string
{
- $this->debug = [];
-
- return $this;
+ return $this->database;
}
/**
@@ -109,11 +183,10 @@ public function resetDebug(): static
*
* Set namespace to divide different scope of data sets
*
- * @param string $namespace
*
* @return $this
- * @throws DatabaseException
*
+ * @throws DatabaseException
*/
public function setNamespace(string $namespace): static
{
@@ -126,9 +199,6 @@ public function setNamespace(string $namespace): static
* Get Namespace.
*
* Get namespace of current set scope
- *
- * @return string
- *
*/
public function getNamespace(): string
{
@@ -138,7 +208,6 @@ public function getNamespace(): string
/**
* Set Hostname.
*
- * @param string $hostname
* @return $this
*/
public function setHostname(string $hostname): static
@@ -150,52 +219,16 @@ public function setHostname(string $hostname): static
/**
* Get Hostname.
- *
- * @return string
*/
public function getHostname(): string
{
return $this->hostname;
}
- /**
- * Set Database.
- *
- * Set database to use for current scope
- *
- * @param string $name
- *
- * @return bool
- * @throws DatabaseException
- */
- public function setDatabase(string $name): bool
- {
- $this->database = $this->filter($name);
-
- return true;
- }
-
- /**
- * Get Database.
- *
- * Get Database from current scope
- *
- * @return string
- *
- */
- public function getDatabase(): string
- {
- return $this->database;
- }
-
/**
* Set Shared Tables.
*
* Set whether to share tables between tenants
- *
- * @param bool $sharedTables
- *
- * @return bool
*/
public function setSharedTables(bool $sharedTables): bool
{
@@ -208,8 +241,6 @@ public function setSharedTables(bool $sharedTables): bool
* Get Share Tables.
*
* Get whether to share tables between tenants
- *
- * @return bool
*/
public function getSharedTables(): bool
{
@@ -220,10 +251,6 @@ public function getSharedTables(): bool
* Set Tenant.
*
* Set tenant to use if tables are shared
- *
- * @param int|string|null $tenant
- *
- * @return bool
*/
public function setTenant(int|string|null $tenant): bool
{
@@ -235,12 +262,20 @@ public function setTenant(int|string|null $tenant): bool
/**
* Get Tenant.
*
- * Get tenant to use for shared tables
+ * Get tenant to use for shared tables.
*
- * @return int|string|null
+ * `_tenant` is an INT UNSIGNED column, so the engine reads "001" and "1"
+ * as the same tenant and returns both rows for either. Normalising every
+ * digit-only string mirrors that. Keeping them apart in PHP would be worse
+ * than the collapse: the scope comparison and the cache key would claim a
+ * distinction the rows do not have.
*/
public function getTenant(): int|string|null
{
+ if (\is_string($this->tenant) && \ctype_digit($this->tenant)) {
+ return (int) $this->tenant;
+ }
+
return $this->tenant;
}
@@ -248,10 +283,6 @@ public function getTenant(): int|string|null
* Set Tenant Per Document.
*
* Set whether to use a different tenant for each document
- *
- * @param bool $tenantPerDocument
- *
- * @return bool
*/
public function setTenantPerDocument(bool $tenantPerDocument): bool
{
@@ -264,34 +295,57 @@ public function setTenantPerDocument(bool $tenantPerDocument): bool
* Get Tenant Per Document.
*
* Get whether to use a different tenant for each document
- *
- * @return bool
*/
public function getTenantPerDocument(): bool
{
return $this->tenantPerDocument;
}
+ /**
+ * Set a debug key-value pair for diagnostic purposes.
+ *
+ * @param string $key The debug key.
+ * @param mixed $value The debug value.
+ * @return $this
+ */
+ public function setDebug(string $key, mixed $value): static
+ {
+ $this->debug[$key] = $value;
+
+ return $this;
+ }
+
+ /**
+ * Get all collected debug data.
+ *
+ * @return array
+ */
+ public function getDebug(): array
+ {
+ return $this->debug;
+ }
+
+ /**
+ * Reset all debug data.
+ *
+ * @return $this
+ */
+ public function resetDebug(): static
+ {
+ $this->debug = [];
+
+ return $this;
+ }
+
/**
* Set metadata for query comments
*
- * @param string $key
- * @param mixed $value
* @return $this
*/
public function setMetadata(string $key, mixed $value): static
{
$this->metadata[$key] = $value;
- $output = '';
- foreach ($this->metadata as $key => $value) {
- $output .= "/* {$key}: {$value} */\n";
- }
-
- $this->before(Database::EVENT_ALL, 'metadata', function ($query) use ($output) {
- return $output . $query;
- });
-
return $this;
}
@@ -317,45 +371,177 @@ public function resetMetadata(): static
return $this;
}
+ protected function setTimeoutState(int $milliseconds, Event $event): void
+ {
+ $this->timeouts[$event->value] = $milliseconds;
+
+ if ($event === Event::All) {
+ $this->timeout = $milliseconds;
+ }
+ }
+
/**
- * Set a global timeout for database queries in milliseconds.
+ * Get the current query timeout value.
*
- * This function allows you to set a maximum execution time for all database
- * queries executed using the library, or a specific event specified by the
- * event parameter. Once this timeout is set, any database query that takes
- * longer than the specified time will be automatically terminated by the library,
- * and an appropriate error or exception will be raised to handle the timeout condition.
+ * @return int Timeout in milliseconds, or 0 if no timeout is set.
+ */
+ public function getTimeout(Event $event = Event::All): int
+ {
+ return $this->timeouts[$event->value]
+ ?? $this->timeouts[Event::All->value]
+ ?? $this->timeout;
+ }
+
+ protected function clearTimeoutState(Event $event): void
+ {
+ if ($event === Event::All) {
+ $this->timeouts = [];
+ $this->timeout = 0;
+
+ return;
+ }
+
+ unset($this->timeouts[$event->value]);
+ }
+
+ /**
+ * Enable or disable LOCK=SHARED during ALTER TABLE operations.
*
- * @param int $milliseconds The timeout value in milliseconds for database queries.
- * @param string $event The event the timeout should fire for
- * @return void
+ * @param bool $enable True to enable alter locks.
+ * @return $this
+ */
+ public function enableAlterLocks(bool $enable): self
+ {
+ $this->alterLocks = $enable;
+
+ return $this;
+ }
+
+ public function getAlterLocks(): bool
+ {
+ return $this->alterLocks;
+ }
+
+ /**
+ * Set support for attributes
+ */
+ abstract public function setSupportForAttributes(bool $support): bool;
+
+ /**
+ * Register a write hook that intercepts document write operations.
*
- * @throws Exception The provided timeout value must be greater than or equal to 0.
+ * @param Write $hook The write hook to add.
+ * @return $this
*/
- abstract public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void;
+ public function addWriteHook(Write $hook): static
+ {
+ $this->writeHooks[] = $hook;
+
+ return $this;
+ }
+
+ public function hasPermissionHook(): bool
+ {
+ foreach ($this->writeHooks as $hook) {
+ if ($hook instanceof Hook\Permissions) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function hasTenantHook(): bool
+ {
+ return $this->getTenantHook() !== null;
+ }
- public function getTimeout(): int
+ public function getTenantHook(): ?Hook\Tenancy
{
- return $this->timeout;
+ foreach ($this->writeHooks as $hook) {
+ if ($hook instanceof Hook\Tenancy) {
+ return $hook;
+ }
+ }
+
+ return null;
}
/**
- * Clears a global timeout for database queries.
+ * Remove a write hook by its class name.
*
- * @param string $event
- * @return void
+ * @param string $class The fully qualified class name of the hook to remove.
+ * @return $this
+ */
+ public function removeWriteHook(string $class): static
+ {
+ $this->writeHooks = \array_values(\array_filter(
+ $this->writeHooks,
+ fn (Write $h) => ! ($h instanceof $class)
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Get all registered write hooks.
+ *
+ * @return list
+ */
+ public function getWriteHooks(): array
+ {
+ return $this->writeHooks;
+ }
+
+ /**
+ * Register a named query transform hook that modifies queries before execution.
+ *
+ * @param string $name Unique name for the transform.
+ * @param Transform $transform The query transform hook to add.
+ * @return $this
+ */
+ public function addTransform(string $name, Transform $transform): static
+ {
+ $this->queryTransforms[$name] = $transform;
+
+ return $this;
+ }
+
+ /**
+ * Remove a query transform hook by name.
+ *
+ * @param string $name The name of the transform to remove.
+ * @return $this
+ */
+ public function removeTransform(string $name): static
+ {
+ unset($this->queryTransforms[$name]);
+
+ return $this;
+ }
+
+ /**
+ * Remove all registered query transform hooks.
+ *
+ * @return $this
*/
- public function clearTimeout(string $event): void
+ public function resetTransforms(): static
{
- // Clear existing callback
- $this->before($event, 'timeout');
+ $this->queryTransforms = [];
- // Adapters that apply the timeout from this property on every statement
- // (e.g. Postgres SET statement_timeout) would otherwise keep enforcing a
- // cleared timeout on all subsequent queries.
- $this->timeout = 0;
+ return $this;
}
+ /**
+ * Ping Database
+ */
+ abstract public function ping(): bool;
+
+ /**
+ * Reconnect Database
+ */
+ abstract public function reconnect(): void;
+
/**
* Clears every timeout this adapter carries, for any event.
*
@@ -367,11 +553,9 @@ public function clearTimeout(string $event): void
*/
public function clearTimeouts(): void
{
- foreach (\array_keys($this->transformations) as $event) {
- $this->clearTimeout($event);
- }
-
- $this->timeout = 0;
+ // Event::All empties the whole map rather than unsetting one entry, so
+ // this needs no knowledge of which events a previous holder set.
+ $this->clearTimeoutState(Event::All);
}
/**
@@ -379,7 +563,6 @@ public function clearTimeouts(): void
*
* If a transaction is already active, this will only increment the transaction count and return true.
*
- * @return bool
* @throws DatabaseException
*/
abstract public function startTransaction(): bool;
@@ -391,7 +574,6 @@ abstract public function startTransaction(): bool;
* If there is more than one active transaction, this decrement the transaction count and return true.
* If the transaction count is 1, it will be commited, the transaction count will be reset to 0, and return true.
*
- * @return bool
* @throws DatabaseException
*/
abstract public function commitTransaction(): bool;
@@ -402,15 +584,12 @@ abstract public function commitTransaction(): bool;
* If no transaction is active, this will be a no-op and will return false.
* If 1 or more transactions are active, this will roll back all transactions, reset the count to 0, and return true.
*
- * @return bool
* @throws DatabaseException
*/
abstract public function rollbackTransaction(): bool;
/**
* Check if a transaction is active.
- *
- * @return bool
*/
public function inTransaction(): bool
{
@@ -440,9 +619,11 @@ public function skipDuplicates(callable $callback): mixed
/**
* @template T
- * @param callable(): T $callback
+ *
+ * @param callable(): T $callback
* @return T
- * @throws \Throwable
+ *
+ * @throws Throwable
*/
public function withTransaction(callable $callback): mixed
{
@@ -454,15 +635,13 @@ public function withTransaction(callable $callback): mixed
$this->startTransaction();
$result = $callback();
$this->commitTransaction();
+
return $result;
- } catch (\Throwable $action) {
+ } catch (Throwable $action) {
$rollback = null;
try {
$this->rollbackTransaction();
- } catch (\Throwable $rollbackError) {
- // Not every adapter resets the depth counter when its
- // rollback throws (e.g. Redis), so reset it here to avoid
- // leaking transaction state onto the reused connection.
+ } catch (Throwable $rollbackError) {
$rollback = $rollbackError;
$this->inTransaction = 0;
}
@@ -481,6 +660,7 @@ public function withTransaction(callable $callback): mixed
if ($attempts < $retries) {
\usleep($sleep * ($attempts + 1));
+
continue;
}
@@ -492,79 +672,18 @@ public function withTransaction(callable $callback): mixed
}
/**
- * Apply a transformation to a query before an event occurs
+ * Create Database
+ */
+ abstract public function create(string $name): bool;
+
+ /**
+ * Check if database exists
+ * Optionally check if collection exists in database
*
- * @param string $event
- * @param string $name
- * @param ?callable $callback
- * @return static
+ * @param string $database database name
+ * @param string|null $collection (optional) collection name
*/
- public function before(string $event, string $name = '', ?callable $callback = null): static
- {
- if (!isset($this->transformations[$event])) {
- $this->transformations[$event] = [];
- }
-
- if (\is_null($callback)) {
- unset($this->transformations[$event][$name]);
- } else {
- $this->transformations[$event][$name] = $callback;
- }
-
- return $this;
- }
-
- protected function trigger(string $event, mixed $query): mixed
- {
- foreach ($this->transformations[Database::EVENT_ALL] as $callback) {
- $query = $callback($query);
- }
- foreach (($this->transformations[$event] ?? []) as $callback) {
- $query = $callback($query);
- }
-
- return $query;
- }
-
- /**
- * Quote a string
- *
- * @param string $string
- * @return string
- */
- abstract protected function quote(string $string): string;
-
- /**
- * Ping Database
- *
- * @return bool
- */
- abstract public function ping(): bool;
-
- /**
- * Reconnect Database
- */
- abstract public function reconnect(): void;
-
- /**
- * Create Database
- *
- * @param string $name
- *
- * @return bool
- */
- abstract public function create(string $name): bool;
-
- /**
- * Check if database exists
- * Optionally check if collection exists in database
- *
- * @param string $database database name
- * @param string|null $collection (optional) collection name
- *
- * @return bool
- */
- abstract public function exists(string $database, ?string $collection = null): bool;
+ abstract public function exists(string $database, ?string $collection = null): bool;
/**
* List Databases
@@ -575,61 +694,40 @@ abstract public function list(): array;
/**
* Delete Database
- *
- * @param string $name
- *
- * @return bool
*/
abstract public function delete(string $name): bool;
/**
* Create Collection
*
- * @param string $name
- * @param array $attributes (optional)
- * @param array $indexes (optional)
- * @return bool
+ * @param array $attributes (optional)
+ * @param array $indexes (optional)
*/
abstract public function createCollection(string $name, array $attributes = [], array $indexes = []): bool;
/**
* Delete Collection
- *
- * @param string $id
- *
- * @return bool
*/
abstract public function deleteCollection(string $id): bool;
/**
* Analyze a collection updating its metadata on the database engine
- *
- * @param string $collection
- * @return bool
*/
abstract public function analyzeCollection(string $collection): bool;
/**
* Create Attribute
*
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param int $size
- * @param bool $signed
- * @param bool $array
- * @return bool
* @throws TimeoutException
* @throws DuplicateException
*/
- abstract public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool;
+ abstract public function createAttribute(string $collection, Attribute $attribute): bool;
/**
* Create Attributes
*
- * @param string $collection
- * @param array> $attributes
- * @return bool
+ * @param array $attributes
+ *
* @throws TimeoutException
* @throws DuplicateException
*/
@@ -637,145 +735,44 @@ abstract public function createAttributes(string $collection, array $attributes)
/**
* Update Attribute
- *
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param int $size
- * @param bool $signed
- * @param bool $array
- * @param string|null $newKey
- * @param bool $required
- *
- * @return bool
*/
- abstract public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool;
+ abstract public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool;
/**
* Delete Attribute
- *
- * @param string $collection
- * @param string $id
- *
- * @return bool
*/
abstract public function deleteAttribute(string $collection, string $id): bool;
/**
* Rename Attribute
- *
- * @param string $collection
- * @param string $old
- * @param string $new
- * @return bool
*/
abstract public function renameAttribute(string $collection, string $old, string $new): bool;
/**
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $id
- * @param string $twoWayKey
- * @return bool
- */
- abstract public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool;
-
- /**
- * Update Relationship
- *
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $key
- * @param string $twoWayKey
- * @param string $side
- * @param string|null $newKey
- * @param string|null $newTwoWayKey
- * @return bool
- */
- abstract public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool;
-
- /**
- * Delete Relationship
- *
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $key
- * @param string $twoWayKey
- * @param string $side
- * @return bool
+ * @param array $indexAttributeTypes
+ * @param array $collation
*/
- abstract public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool;
-
- /**
- * Rename Index
- *
- * @param string $collection
- * @param string $old
- * @param string $new
- * @return bool
- */
- abstract public function renameIndex(string $collection, string $old, string $new): bool;
-
- /**
- * Create Index
- *
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param array $attributes
- * @param array $lengths
- * @param array $orders
- * @param array $indexAttributeTypes
- * @param array $collation
- * @param int $ttl
- *
- * @return bool
- */
- abstract public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool;
+ abstract public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool;
/**
* Delete Index
- *
- * @param string $collection
- * @param string $id
- *
- * @return bool
*/
abstract public function deleteIndex(string $collection, string $id): bool;
/**
- * Get Document
- *
- * @param Document $collection
- * @param string $id
- * @param array $queries
- * @param bool $forUpdate
- * @return Document
+ * Rename Index
*/
- abstract public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document;
+ abstract public function renameIndex(string $collection, string $old, string $new): bool;
/**
* Create Document
- *
- * @param Document $collection
- * @param Document $document
- *
- * @return Document
*/
abstract public function createDocument(Document $collection, Document $document): Document;
/**
* Create Documents in batches
*
- * @param Document $collection
- * @param array $documents
- *
+ * @param array $documents
* @return array
*
* @throws DatabaseException
@@ -783,14 +780,14 @@ abstract public function createDocument(Document $collection, Document $document
abstract public function createDocuments(Document $collection, array $documents): array;
/**
- * Update Document
- *
- * @param Document $collection
- * @param string $id
- * @param Document $document
- * @param bool $skipPermissions
+ * Get Document
*
- * @return Document
+ * @param array $queries
+ */
+ abstract public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document;
+
+ /**
+ * Update Document
*/
abstract public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document;
@@ -799,57 +796,37 @@ abstract public function updateDocument(Document $collection, string $id, Docume
*
* Updates all documents which match the given query.
*
- * @param Document $collection
- * @param Document $updates
- * @param array $documents
- *
- * @return int
+ * @param array $documents
*
* @throws DatabaseException
*/
abstract public function updateDocuments(Document $collection, Document $updates, array $documents): int;
/**
- * Create documents if they do not exist, otherwise update them.
- *
- * If attribute is not empty, only the specified attribute will be increased, by the new value in each document.
+ * Increase or decrease attribute value
*
- * @param Document $collection
- * @param string $attribute
- * @param array $changes
- * @return array
+ * @throws Exception
*/
- abstract public function upsertDocuments(
- Document $collection,
+ abstract public function increaseDocumentAttribute(
+ string $collection,
+ string $id,
string $attribute,
- array $changes
- ): array;
-
- /**
- * @param string $collection
- * @param array $documents
- * @return array
- */
- abstract public function getSequences(string $collection, array $documents): array;
+ int|float|string $value,
+ string $updatedAt,
+ int|float|string|null $min = null,
+ int|float|string|null $max = null
+ ): bool;
/**
* Delete Document
- *
- * @param string $collection
- * @param string $id
- *
- * @return bool
*/
abstract public function deleteDocument(string $collection, string $id): bool;
/**
* Delete Documents
*
- * @param string $collection
- * @param array $sequences
- * @param array $permissionIds
- *
- * @return int
+ * @param array $sequences
+ * @param array $permissionIds
*/
abstract public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int;
@@ -858,71 +835,41 @@ abstract public function deleteDocuments(string $collection, array $sequences, a
*
* Find data sets using chosen queries
*
- * @param Document $collection
- * @param array $queries
- * @param int|null $limit
- * @param int|null $offset
- * @param array $orderAttributes
- * @param array $orderTypes
- * @param array $cursor
- * @param string $cursorDirection
- * @param string $forPermission
+ * @param array $queries
+ * @param array $orderAttributes
+ * @param array<\Utopia\Query\OrderDirection> $orderTypes
+ * @param array $cursor
* @return array
*/
- abstract public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array;
-
- /**
- * Sum an attribute
- *
- * @param Document $collection
- * @param string $attribute
- * @param array $queries
- * @param int|null $max
- *
- * @return int|float
- */
- abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int;
+ abstract public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array;
/**
* Count Documents
*
- * @param Document $collection
- * @param array $queries
- * @param int|null $max
- *
- * @return int
+ * @param array $queries
*/
abstract public function count(Document $collection, array $queries = [], ?int $max = null): int;
/**
- * Get Collection Size of the raw data
+ * Sum an attribute
*
- * @param string $collection
- * @return int
- * @throws DatabaseException
+ * @param array $queries
*/
- abstract public function getSizeOfCollection(string $collection): int;
+ abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int;
/**
- * Get Collection Size on the disk
- *
- * @param string $collection
- * @return int
- * @throws DatabaseException
+ * @param array $documents
+ * @return array
*/
- abstract public function getSizeOfCollectionOnDisk(string $collection): int;
+ abstract public function getSequences(string $collection, array $documents): array;
/**
* Get max STRING limit
- *
- * @return int
*/
abstract public function getLimitForString(): int;
/**
* Get max INT limit
- *
- * @return int
*/
abstract public function getLimitForInt(): int;
@@ -935,435 +882,234 @@ abstract public function getLimitForBigInt(): int;
/**
* Get maximum attributes limit.
- *
- * @return int
*/
abstract public function getLimitForAttributes(): int;
/**
* Get maximum index limit.
- *
- * @return int
*/
abstract public function getLimitForIndexes(): int;
/**
- * @return int
+ * Get the maximum index key length in bytes.
*/
abstract public function getMaxIndexLength(): int;
/**
* Get the maximum VARCHAR length for this adapter
- *
- * @return int
*/
abstract public function getMaxVarcharLength(): int;
/**
* Get the maximum UID length for this adapter
- *
- * @return int
*/
abstract public function getMaxUIDLength(): int;
/**
* Get the minimum supported DateTime value
- *
- * @return \DateTime
- */
- abstract public function getMinDateTime(): \DateTime;
-
- /**
- * Get the primitive type of the primary key type for this adapter
- *
- * @return string
*/
- abstract public function getIdAttributeType(): string;
+ abstract public function getMinDateTime(): DateTime;
/**
* Get the maximum supported DateTime value
- *
- * @return \DateTime
*/
- public function getMaxDateTime(): \DateTime
+ public function getMaxDateTime(): DateTime
{
- return new \DateTime('9999-12-31 23:59:59');
+ return new DateTime('9999-12-31 23:59:59');
}
/**
- * Is schemas supported?
- *
- * @return bool
- */
- abstract public function getSupportForSchemas(): bool;
-
- /**
- * Are attributes supported?
- *
- * @return bool
- */
- abstract public function getSupportForAttributes(): bool;
-
- /**
- * Are schema attributes supported?
- *
- * @return bool
- */
- abstract public function getSupportForSchemaAttributes(): bool;
-
- /**
- * Are schema indexes supported?
- *
- * @return bool
- */
- abstract public function getSupportForSchemaIndexes(): bool;
-
- /**
- * Is index supported?
- *
- * @return bool
- */
- abstract public function getSupportForIndex(): bool;
-
- /**
- * Is indexing array supported?
- *
- * @return bool
- */
- abstract public function getSupportForIndexArray(): bool;
-
- /**
- * Is cast index as array supported?
- *
- * @return bool
- */
- abstract public function getSupportForCastIndexArray(): bool;
-
- /**
- * Is unique index supported?
- *
- * @return bool
- */
- abstract public function getSupportForUniqueIndex(): bool;
-
- /**
- * Is fulltext index supported?
- *
- * @return bool
- */
- abstract public function getSupportForFulltextIndex(): bool;
-
- /**
- * Is fulltext wildcard supported?
- *
- * @return bool
- */
- abstract public function getSupportForFulltextWildcardIndex(): bool;
-
-
- /**
- * Does the adapter handle casting?
- *
- * @return bool
- */
- abstract public function getSupportForCasting(): bool;
-
- /**
- * Does the adapter handle array Contains?
- *
- * @return bool
- */
- abstract public function getSupportForQueryContains(): bool;
-
- /**
- * Are timeouts supported?
- *
- * @return bool
- */
- abstract public function getSupportForTimeouts(): bool;
-
- /**
- * Are relationships supported?
- *
- * @return bool
- */
- abstract public function getSupportForRelationships(): bool;
-
- abstract public function getSupportForUpdateLock(): bool;
-
- /**
- * Are batch operations supported?
- *
- * @return bool
- */
- abstract public function getSupportForBatchOperations(): bool;
-
- /**
- * Is attribute resizing supported?
- *
- * @return bool
- */
- abstract public function getSupportForAttributeResizing(): bool;
-
- /**
- * Is get connection id supported?
- *
- * @return bool
- */
- abstract public function getSupportForGetConnectionId(): bool;
-
- /**
- * Is upserting supported?
- *
- * @return bool
- */
- abstract public function getSupportForUpserts(): bool;
-
- /**
- * Is upsert via arbitrary unique indexes supported?
- *
- * @return bool
- */
- abstract public function getSupportForUpsertOnUniqueIndex(): bool;
-
- /**
- * Is vector type supported?
- *
- * @return bool
- */
- abstract public function getSupportForVectors(): bool;
-
- /**
- * Is Cache Fallback supported?
- *
- * @return bool
- */
- abstract public function getSupportForCacheSkipOnFailure(): bool;
-
- /**
- * @return bool
+ * Get the primitive type of the primary key type for this adapter
*/
- abstract public function getSupportForCaching(): bool;
+ abstract public function getIdAttributeType(): string;
/**
- * Is reconnection supported?
+ * Get Collection Size of the raw data
*
- * @return bool
+ * @throws DatabaseException
*/
- abstract public function getSupportForReconnection(): bool;
+ abstract public function getSizeOfCollection(string $collection): int;
/**
- * Is hostname supported?
+ * Get Collection Size on the disk
*
- * @return bool
+ * @throws DatabaseException
*/
- abstract public function getSupportForHostname(): bool;
+ abstract public function getSizeOfCollectionOnDisk(string $collection): int;
/**
- * Is creating multiple attributes in a single query supported?
- *
- * @return bool
+ * Get maximum width, in bytes, allowed for a SQL row
+ * Return 0 when no restrictions apply
*/
- abstract public function getSupportForBatchCreateAttributes(): bool;
+ abstract public function getDocumentSizeLimit(): int;
/**
- * Is spatial attributes supported?
- *
- * @return bool
+ * Estimate maximum number of bytes required to store a document in $collection.
+ * Byte requirement varies based on column type and size.
+ * Needed to satisfy MariaDB/MySQL row width limit.
+ * Return 0 when no restrictions apply to row width
*/
- abstract public function getSupportForSpatialAttributes(): bool;
+ abstract public function getAttributeWidth(Document $collection): int;
/**
- * Are object (JSON) attributes supported?
- *
- * @return bool
+ * Get current attribute count from collection document
*/
- abstract public function getSupportForObject(): bool;
+ abstract public function getCountOfAttributes(Document $collection): int;
/**
- * Are object (JSON) indexes supported?
- *
- * @return bool
+ * Get current index count from collection document
*/
- abstract public function getSupportForObjectIndexes(): bool;
+ abstract public function getCountOfIndexes(Document $collection): int;
/**
- * Does the adapter support null values in spatial indexes?
- *
- * @return bool
+ * Returns number of attributes used by default.
*/
- abstract public function getSupportForSpatialIndexNull(): bool;
+ abstract public function getCountOfDefaultAttributes(): int;
/**
- * Does the adapter support operators?
- *
- * @return bool
+ * Returns number of indexes used by default.
*/
- abstract public function getSupportForOperators(): bool;
+ abstract public function getCountOfDefaultIndexes(): int;
/**
- * Adapter supports optional spatial attributes with existing rows.
+ * Get list of keywords that cannot be used
*
- * @return bool
+ * @return array
*/
- abstract public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool;
+ abstract public function getKeywords(): array;
/**
- * Does the adapter support order attribute in spatial indexes?
+ * Get List of internal index keys names
*
- * @return bool
+ * @return array
*/
- abstract public function getSupportForSpatialIndexOrder(): bool;
+ abstract public function getInternalIndexesKeys(): array;
- /**
- * Does the adapter support spatial axis order specification?
- *
- * @return bool
- */
- abstract public function getSupportForSpatialAxisOrder(): bool;
+ protected function getInternalKeyForAttribute(string $attribute): string
+ {
+ return Storage::column($attribute);
+ }
/**
- * Does the adapter includes boundary during spatial contains?
+ * Get the query to check for tenant when in shared tables mode
*
- * @return bool
+ * @param string $collection The collection being queried
+ * @param string $alias The alias of the parent collection if in a subquery
*/
- abstract public function getSupportForBoundaryInclusiveContains(): bool;
+ abstract public function getTenantQuery(string $collection, string $alias = ''): string;
/**
- * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)?
- *
- * @return bool
+ * Handle non utf characters supported?
*/
- abstract public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool;
+ public function getSupportNonUtfCharacters(): bool
+ {
+ return false;
+ }
/**
- * Does the adapter support multiple fulltext indexes?
+ * Process-lifetime cache for {@see self::filter()}. Keys are referentially
+ * stable across the request lifetime and frequently re-queried per-row, so
+ * caching the regex result amortizes the preg_replace cost across all
+ * decode/encode/build passes. Bounded to avoid unbounded growth from
+ * unusual input.
*
- * @return bool
+ * @var array
*/
- abstract public function getSupportForMultipleFulltextIndexes(): bool;
+ private static array $filteredKeyCache = [];
+ private const FILTERED_KEY_CACHE_LIMIT = 4096;
/**
- * Does the adapter support identical indexes?
+ * Filter Keys
*
- * @return bool
+ * @throws DatabaseException
*/
- abstract public function getSupportForIdenticalIndexes(): bool;
+ public function filter(string $value): string
+ {
+ if (isset(self::$filteredKeyCache[$value])) {
+ return self::$filteredKeyCache[$value];
+ }
- /**
- * Does the adapter support random order by?
- *
- * @return bool
- */
- abstract public function getSupportForOrderRandom(): bool;
+ $filtered = \preg_replace("/[^A-Za-z0-9_\-]/", '', $value);
- /**
- * Get current attribute count from collection document
- *
- * @param Document $collection
- * @return int
- */
- abstract public function getCountOfAttributes(Document $collection): int;
+ if (\is_null($filtered)) {
+ throw new DatabaseException('Failed to filter key');
+ }
- /**
- * Get current index count from collection document
- *
- * @param Document $collection
- * @return int
- */
- abstract public function getCountOfIndexes(Document $collection): int;
+ if (\count(self::$filteredKeyCache) >= self::FILTERED_KEY_CACHE_LIMIT) {
+ self::$filteredKeyCache = [];
+ }
- /**
- * Returns number of attributes used by default.
- *
- * @return int
- */
- abstract public function getCountOfDefaultAttributes(): int;
+ return self::$filteredKeyCache[$value] = $filtered;
+ }
/**
- * Returns number of indexes used by default.
+ * Apply all write hooks' decorateRow to a row.
*
- * @return int
+ * @param array $row
+ * @param array $metadata
+ * @return array
*/
- abstract public function getCountOfDefaultIndexes(): int;
+ protected function decorateRow(array $row, array $metadata): array
+ {
+ foreach ($this->writeHooks as $hook) {
+ $row = $hook->decorateRow($row, $metadata);
+ }
- /**
- * Get maximum width, in bytes, allowed for a SQL row
- * Return 0 when no restrictions apply
- *
- * @return int
- */
- abstract public function getDocumentSizeLimit(): int;
+ return $row;
+ }
/**
- * Estimate maximum number of bytes required to store a document in $collection.
- * Byte requirement varies based on column type and size.
- * Needed to satisfy MariaDB/MySQL row width limit.
- * Return 0 when no restrictions apply to row width
+ * Run all write hooks concurrently when more than one is registered,
+ * otherwise run sequentially. The provided callable receives a single
+ * Write hook instance.
*
- * @param Document $collection
- * @return int
+ * @param callable(Write): void $fn
*/
- abstract public function getAttributeWidth(Document $collection): int;
+ protected function runWriteHooks(callable $fn): void
+ {
+ foreach ($this->writeHooks as $hook) {
+ $fn($hook);
+ }
+ }
/**
- * Get list of keywords that cannot be used
- *
- * @return array
+ * @return array
*/
- abstract public function getKeywords(): array;
+ protected function documentMetadata(Document $document): array
+ {
+ return ['id' => $document->getId(), 'tenant' => $document->getTenant()];
+ }
/**
* Get an attribute projection given a list of selected attributes
*
- * @param array $selections
- * @param string $prefix
- * @return mixed
+ * @param array $selections
*/
abstract protected function getAttributeProjection(array $selections, string $prefix): mixed;
/**
* Get all selected attributes from queries
*
- * @param Query[] $queries
- * @return string[]
+ * @param array $queries
+ * @return array
*/
protected function getAttributeSelections(array $queries): array
{
$selections = [];
foreach ($queries as $query) {
- switch ($query->getMethod()) {
- case Query::TYPE_SELECT:
- foreach ($query->getValues() as $value) {
- $selections[] = $value;
- }
- break;
+ if ($query->getMethod() === Method::Select) {
+ foreach ($query->getValues() as $value) {
+ /** @var string $value */
+ $selections[] = $value;
+ }
}
}
return $selections;
}
- /**
- * Filter Keys
- *
- * @param string $value
- * @return string
- * @throws DatabaseException
- */
- public function filter(string $value): string
- {
- $value = \preg_replace("/[^A-Za-z0-9_\-]/", '', $value);
-
- if (\is_null($value)) {
- throw new DatabaseException('Failed to filter key');
- }
-
- return $value;
- }
-
protected function escapeWildcards(string $value): string
{
$wildcards = [
@@ -1381,7 +1127,7 @@ protected function escapeWildcards(string $value): string
')',
'{',
'}',
- '|'
+ '|',
];
foreach ($wildcards as $wildcard) {
@@ -1392,266 +1138,12 @@ protected function escapeWildcards(string $value): string
}
/**
- * Increase or decrease attribute value
- *
- * @param string $collection
- * @param string $id
- * @param string $attribute
- * @param int|float $value
- * @param string $updatedAt
- * @param int|float|null $min
- * @param int|float|null $max
- * @return bool
- * @throws Exception
- */
- abstract public function increaseDocumentAttribute(
- string $collection,
- string $id,
- string $attribute,
- int|float $value,
- string $updatedAt,
- int|float|null $min = null,
- int|float|null $max = null
- ): bool;
-
- /**
- * Returns the connection ID identifier
- *
- * @return string
- */
- abstract public function getConnectionId(): string;
-
- /**
- * Get List of internal index keys names
- *
- * @return array
- */
- abstract public function getInternalIndexesKeys(): array;
-
- /**
- * Get Schema Attributes
- *
- * @param string $collection
- * @return array
- * @throws DatabaseException
- */
- abstract public function getSchemaAttributes(string $collection): array;
-
- /**
- * Get Schema Indexes
- *
- * Returns physical index definitions from the database schema.
- *
- * @param string $collection
- * @return array
- * @throws DatabaseException
- */
- abstract public function getSchemaIndexes(string $collection): array;
-
- /**
- * Get the expected column type for a given attribute type.
- *
- * Returns the database-native column type string (e.g. "VARCHAR(255)", "BIGINT")
- * that would be used when creating a column for the given attribute parameters.
- * Returns an empty string if the adapter does not support this operation.
- *
- * @param string $type
- * @param int $size
- * @param bool $signed
- * @param bool $array
- * @param bool $required
- * @return string
- * @throws \Utopia\Database\Exception For unknown types on adapters that support column-type resolution.
- */
- public function getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string
- {
- return '';
- }
-
- /**
- * Get the query to check for tenant when in shared tables mode
- *
- * @param string $collection The collection being queried
- * @param string $alias The alias of the parent collection if in a subquery
- * @return string
+ * Quote a string
*/
- abstract public function getTenantQuery(string $collection, string $alias = ''): string;
+ abstract protected function quote(string $string): string;
- /**
- * @param mixed $stmt
- * @return bool
- */
abstract protected function execute(mixed $stmt): bool;
- /**
- * Decode a WKB or textual POINT into [x, y]
- *
- * @param string $wkb
- * @return float[] Array with two elements: [x, y]
- */
- abstract public function decodePoint(string $wkb): array;
-
- /**
- * Decode a WKB or textual LINESTRING into [[x1, y1], [x2, y2], ...]
- *
- * @param string $wkb
- * @return float[][] Array of points, each as [x, y]
- */
- abstract public function decodeLinestring(string $wkb): array;
-
- /**
- * Decode a WKB or textual POLYGON into [[[x1, y1], [x2, y2], ...], ...]
- *
- * @param string $wkb
- * @return float[][][] Array of rings, each ring is an array of points [x, y]
- */
- abstract public function decodePolygon(string $wkb): array;
-
- public function getSupportForUnsignedBigInt(): bool
- {
- return false;
- }
-
- /**
- * Returns the document after casting
- * @param Document $collection
- * @param Document $document
- * @return Document
- */
- abstract public function castingBefore(Document $collection, Document $document): Document;
-
- /**
- * Returns the document after casting
- * @param Document $collection
- * @param Document $document
- * @return Document
- */
- abstract public function castingAfter(Document $collection, Document $document): Document;
-
- /**
- * Is internal casting supported?
- *
- * @return bool
- */
- abstract public function getSupportForInternalCasting(): bool;
-
- /**
- * Is UTC casting supported?
- *
- * @return bool
- */
- abstract public function getSupportForUTCCasting(): bool;
-
- /**
- * Set UTC Datetime
- *
- * @param string $value
- * @return mixed
- */
- abstract public function setUTCDatetime(string $value): mixed;
-
- /**
- * Set support for attributes
- *
- * @param bool $support
- * @return bool
- */
- abstract public function setSupportForAttributes(bool $support): bool;
-
- /**
- * Does the adapter require booleans to be converted to integers (0/1)?
- *
- * @return bool
- */
- abstract public function getSupportForIntegerBooleans(): bool;
-
- /**
- * Does the adapter have support for ALTER TABLE locking modes?
- *
- * When enabled, adapters can specify lock behavior (e.g., LOCK=SHARED)
- * during ALTER TABLE operations to control concurrent access.
- *
- * @return bool
- */
- abstract public function getSupportForAlterLocks(): bool;
-
- /**
- * @param bool $enable
- *
- * @return $this
- */
- public function enableAlterLocks(bool $enable): self
- {
- $this->alterLocks = $enable;
-
- return $this;
- }
-
- /**
- * Handle non utf characters supported?
- *
- * @return bool
- */
- abstract public function getSupportNonUtfCharacters(): bool;
-
- /**
- * Does the adapter support trigram index?
- *
- * @return bool
- */
- abstract public function getSupportForTrigramIndex(): bool;
-
- /**
- * Is PCRE regex supported?
- * PCRE (Perl Compatible Regular Expressions) supports \b for word boundaries
- *
- * @return bool
- */
- abstract public function getSupportForPCRERegex(): bool;
-
- /**
- * Is POSIX regex supported?
- * POSIX regex uses \y for word boundaries instead of \b
- *
- * @return bool
- */
- abstract public function getSupportForPOSIXRegex(): bool;
-
- /**
- * Is regex supported at all?
- * Returns true if either PCRE or POSIX regex is supported
- *
- * @return bool
- */
- public function getSupportForRegex(): bool
- {
- return $this->getSupportForPCRERegex() || $this->getSupportForPOSIXRegex();
- }
-
- /**
- * Are ttl indexes supported?
- *
- * @return bool
- */
- public function getSupportForTTLIndexes(): bool
- {
- return false;
- }
-
- /**
- * Does the adapter support transaction retries?
- *
- * @return bool
- */
- abstract public function getSupportForTransactionRetries(): bool;
-
- /**
- * Does the adapter support nested transactions?
- *
- * @return bool
- */
- abstract public function getSupportForNestedTransactions(): bool;
-
/**
* @return mixed
*/
diff --git a/src/Database/Adapter/Feature/Attributes.php b/src/Database/Adapter/Feature/Attributes.php
new file mode 100644
index 0000000000..9594f12634
--- /dev/null
+++ b/src/Database/Adapter/Feature/Attributes.php
@@ -0,0 +1,58 @@
+ $attributes The attributes to create.
+ * @return bool True on success.
+ */
+ public function createAttributes(string $collection, array $attributes): bool;
+
+ /**
+ * Update an existing attribute in a collection.
+ *
+ * @param string $collection The collection identifier.
+ * @param Attribute $attribute The attribute with updated properties.
+ * @param string|null $newKey Optional new key to rename the attribute.
+ * @return bool True on success.
+ */
+ public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool;
+
+ /**
+ * Delete an attribute from a collection.
+ *
+ * @param string $collection The collection identifier.
+ * @param string $id The attribute identifier to delete.
+ * @return bool True on success.
+ */
+ public function deleteAttribute(string $collection, string $id): bool;
+
+ /**
+ * Rename an attribute in a collection.
+ *
+ * @param string $collection The collection identifier.
+ * @param string $old The current attribute key.
+ * @param string $new The new attribute key.
+ * @return bool True on success.
+ */
+ public function renameAttribute(string $collection, string $old, string $new): bool;
+}
diff --git a/src/Database/Adapter/Feature/Collections.php b/src/Database/Adapter/Feature/Collections.php
new file mode 100644
index 0000000000..69d311fca0
--- /dev/null
+++ b/src/Database/Adapter/Feature/Collections.php
@@ -0,0 +1,54 @@
+ $attributes Initial attributes for the collection.
+ * @param array $indexes Initial indexes for the collection.
+ * @return bool True on success.
+ */
+ public function createCollection(string $name, array $attributes = [], array $indexes = []): bool;
+
+ /**
+ * Delete a collection by its identifier.
+ *
+ * @param string $id The collection identifier.
+ * @return bool True on success.
+ */
+ public function deleteCollection(string $id): bool;
+
+ /**
+ * Analyze a collection to update index statistics.
+ *
+ * @param string $collection The collection identifier.
+ * @return bool True on success.
+ */
+ public function analyzeCollection(string $collection): bool;
+
+ /**
+ * Get the logical data size of a collection in bytes.
+ *
+ * @param string $collection The collection identifier.
+ * @return int Size in bytes.
+ */
+ public function getSizeOfCollection(string $collection): int;
+
+ /**
+ * Get the on-disk storage size of a collection in bytes.
+ *
+ * @param string $collection The collection identifier.
+ * @return int Size in bytes.
+ */
+ public function getSizeOfCollectionOnDisk(string $collection): int;
+}
diff --git a/src/Database/Adapter/Feature/ColumnTypes.php b/src/Database/Adapter/Feature/ColumnTypes.php
new file mode 100644
index 0000000000..1bdad576f7
--- /dev/null
+++ b/src/Database/Adapter/Feature/ColumnTypes.php
@@ -0,0 +1,21 @@
+ Array of database documents.
+ */
+ public function list(): array;
+
+ /**
+ * Delete a database by name.
+ *
+ * @param string $name The database name.
+ * @return bool True on success.
+ */
+ public function delete(string $name): bool;
+}
diff --git a/src/Database/Adapter/Feature/Documents.php b/src/Database/Adapter/Feature/Documents.php
new file mode 100644
index 0000000000..ecee61bf4b
--- /dev/null
+++ b/src/Database/Adapter/Feature/Documents.php
@@ -0,0 +1,152 @@
+ $queries Optional queries for field selection.
+ * @param bool $forUpdate Whether to lock the document for update.
+ * @return Document The retrieved document.
+ */
+ public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document;
+
+ /**
+ * Create a new document in a collection.
+ *
+ * @param Document $collection The collection document.
+ * @param Document $document The document to create.
+ * @return Document The created document.
+ */
+ public function createDocument(Document $collection, Document $document): Document;
+
+ /**
+ * Create multiple documents in a collection at once.
+ *
+ * @param Document $collection The collection document.
+ * @param array $documents The documents to create.
+ * @return array The created documents.
+ */
+ public function createDocuments(Document $collection, array $documents): array;
+
+ /**
+ * Update an existing document in a collection.
+ *
+ * @param Document $collection The collection document.
+ * @param string $id The document identifier.
+ * @param Document $document The document with updated data.
+ * @param bool $skipPermissions Whether to skip permission checks.
+ * @return Document The updated document.
+ */
+ public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document;
+
+ /**
+ * Update multiple documents matching the given criteria.
+ *
+ * @param Document $collection The collection document.
+ * @param Document $updates The fields to update.
+ * @param array $documents The documents to update.
+ * @return int The number of documents updated.
+ */
+ public function updateDocuments(Document $collection, Document $updates, array $documents): int;
+
+ /**
+ * Delete a document from a collection.
+ *
+ * @param string $collection The collection identifier.
+ * @param string $id The document identifier.
+ * @return bool True on success.
+ */
+ public function deleteDocument(string $collection, string $id): bool;
+
+ /**
+ * Delete multiple documents from a collection.
+ *
+ * @param string $collection The collection identifier.
+ * @param array $sequences The document sequences to delete.
+ * @param array $permissionIds The permission identifiers to clean up.
+ * @return int The number of documents deleted.
+ */
+ public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int;
+
+ /**
+ * Find documents in a collection matching the given queries and ordering.
+ *
+ * @param Document $collection The collection document.
+ * @param array $queries Filter queries.
+ * @param int|null $limit Maximum number of documents to return.
+ * @param int|null $offset Number of documents to skip.
+ * @param array $orderAttributes Attributes to order by.
+ * @param array $orderTypes Direction for each order attribute.
+ * @param array $cursor Cursor values for pagination.
+ * @param CursorDirection $cursorDirection Direction of cursor pagination.
+ * @param PermissionType $forPermission The permission type to check.
+ * @return array The matching documents.
+ */
+ public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array;
+
+ /**
+ * Calculate the sum of an attribute's values across matching documents.
+ *
+ * @param Document $collection The collection document.
+ * @param string $attribute The attribute to sum.
+ * @param array $queries Optional filter queries.
+ * @param int|null $max Maximum number of documents to consider.
+ * @return float|int The sum result.
+ */
+ public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int;
+
+ /**
+ * Count documents matching the given queries.
+ *
+ * @param Document $collection The collection document.
+ * @param array $queries Optional filter queries.
+ * @param int|null $max Maximum count to return.
+ * @return int The document count.
+ */
+ public function count(Document $collection, array $queries = [], ?int $max = null): int;
+
+ /**
+ * Increase or decrease a numeric attribute value on a document.
+ *
+ * @param string $collection The collection identifier.
+ * @param string $id The document identifier.
+ * @param string $attribute The numeric attribute to modify.
+ * @param int|float|string $value The value to add (negative to decrease).
+ * @param string $updatedAt The timestamp to set as the updated time.
+ * @param int|float|string|null $min Optional minimum bound for the resulting value.
+ * @param int|float|string|null $max Optional maximum bound for the resulting value.
+ * @return bool True on success.
+ */
+ public function increaseDocumentAttribute(
+ string $collection,
+ string $id,
+ string $attribute,
+ int|float|string $value,
+ string $updatedAt,
+ int|float|string|null $min = null,
+ int|float|string|null $max = null
+ ): bool;
+
+ /**
+ * Retrieve internal sequence values for the given documents.
+ *
+ * @param string $collection The collection identifier.
+ * @param array $documents The documents to retrieve sequences for.
+ * @return array The documents with populated sequence values.
+ */
+ public function getSequences(string $collection, array $documents): array;
+}
diff --git a/src/Database/Adapter/Feature/Indexes.php b/src/Database/Adapter/Feature/Indexes.php
new file mode 100644
index 0000000000..14e649331c
--- /dev/null
+++ b/src/Database/Adapter/Feature/Indexes.php
@@ -0,0 +1,48 @@
+ $indexAttributeTypes Mapping of attribute names to their types.
+ * @param array $collation Optional collation settings for the index.
+ * @return bool True on success.
+ */
+ public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool;
+
+ /**
+ * Delete an index from a collection.
+ *
+ * @param string $collection The collection identifier.
+ * @param string $id The index identifier.
+ * @return bool True on success.
+ */
+ public function deleteIndex(string $collection, string $id): bool;
+
+ /**
+ * Rename an index in a collection.
+ *
+ * @param string $collection The collection identifier.
+ * @param string $old The current index name.
+ * @param string $new The new index name.
+ * @return bool True on success.
+ */
+ public function renameIndex(string $collection, string $old, string $new): bool;
+
+ /**
+ * Get the keys of all internal indexes used by the adapter.
+ *
+ * @return array The internal index keys.
+ */
+ public function getInternalIndexesKeys(): array;
+}
diff --git a/src/Database/Adapter/Feature/InternalCasting.php b/src/Database/Adapter/Feature/InternalCasting.php
new file mode 100644
index 0000000000..37a5685543
--- /dev/null
+++ b/src/Database/Adapter/Feature/InternalCasting.php
@@ -0,0 +1,29 @@
+ $bindings Parameter bindings for prepared statements.
+ * @return array The query results as Document objects.
+ */
+ public function rawQuery(string $query, array $bindings = []): array;
+
+ /**
+ * Execute a raw mutation and return the number of affected rows.
+ *
+ * @param string $query The raw mutation string.
+ * @param array $bindings Parameter bindings for prepared statements.
+ * @return int The number of affected rows.
+ */
+ public function rawMutation(string $query, array $bindings = []): int;
+}
diff --git a/src/Database/Adapter/Feature/Relationships.php b/src/Database/Adapter/Feature/Relationships.php
new file mode 100644
index 0000000000..1fe5785a23
--- /dev/null
+++ b/src/Database/Adapter/Feature/Relationships.php
@@ -0,0 +1,37 @@
+ The attribute documents describing the schema.
+ */
+ public function getSchemaAttributes(string $collection): array;
+}
diff --git a/src/Database/Adapter/Feature/SchemaIndexes.php b/src/Database/Adapter/Feature/SchemaIndexes.php
new file mode 100644
index 0000000000..632735097b
--- /dev/null
+++ b/src/Database/Adapter/Feature/SchemaIndexes.php
@@ -0,0 +1,19 @@
+ The index documents describing the schema.
+ */
+ public function getSchemaIndexes(string $collection): array;
+}
diff --git a/src/Database/Adapter/Feature/Spatial.php b/src/Database/Adapter/Feature/Spatial.php
new file mode 100644
index 0000000000..81c120bc95
--- /dev/null
+++ b/src/Database/Adapter/Feature/Spatial.php
@@ -0,0 +1,33 @@
+ The point as [longitude, latitude].
+ */
+ public function decodePoint(string $wkb): array;
+
+ /**
+ * Decode a WKB-encoded linestring into an array of coordinate pairs.
+ *
+ * @param string $wkb The Well-Known Binary representation.
+ * @return array> Array of [longitude, latitude] pairs.
+ */
+ public function decodeLinestring(string $wkb): array;
+
+ /**
+ * Decode a WKB-encoded polygon into an array of rings, each containing coordinate pairs.
+ *
+ * @param string $wkb The Well-Known Binary representation.
+ * @return array>> Array of rings, each an array of [longitude, latitude] pairs.
+ */
+ public function decodePolygon(string $wkb): array;
+}
diff --git a/src/Database/Adapter/Feature/Timeouts.php b/src/Database/Adapter/Feature/Timeouts.php
new file mode 100644
index 0000000000..c5694dcafc
--- /dev/null
+++ b/src/Database/Adapter/Feature/Timeouts.php
@@ -0,0 +1,28 @@
+ $changes The old/new document pairs to upsert.
+ * @return array The resulting documents after upsert.
+ */
+ public function upsertDocuments(Document $collection, string $attribute, array $changes): array;
+}
diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php
index 6d2aac8ef7..36b6990aa9 100644
--- a/src/Database/Adapter/MariaDB.php
+++ b/src/Database/Adapter/MariaDB.php
@@ -3,9 +3,16 @@
namespace Utopia\Database\Adapter;
use Exception;
+use PDO;
use PDOException;
+use PDOStatement;
+use Swoole\Database\PDOStatementProxy;
+use Throwable;
+use Utopia\Database\Attribute;
+use Utopia\Database\Capability;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Event;
use Utopia\Database\Exception as DatabaseException;
use Utopia\Database\Exception\Character as CharacterException;
use Utopia\Database\Exception\Duplicate as DuplicateException;
@@ -16,212 +23,244 @@
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Exception\Truncate as TruncateException;
use Utopia\Database\Exception\Unique as UniqueException;
-use Utopia\Database\Helpers\ID;
+use Utopia\Database\Index;
use Utopia\Database\Operator;
+use Utopia\Database\OperatorType;
+use Utopia\Database\PDOStatement as DatabasePDOStatement;
use Utopia\Database\Query;
-
-class MariaDB extends SQL
+use Utopia\Database\RelationSide;
+use Utopia\Database\RelationType;
+use Utopia\Database\Storage;
+use Utopia\Query\Builder\MariaDB as MariaDBBuilder;
+use Utopia\Query\Builder\SQL as SQLBuilder;
+use Utopia\Query\Method;
+use Utopia\Query\Query as BaseQuery;
+use Utopia\Query\Schema\ColumnType;
+use Utopia\Query\Schema\IndexType;
+use Utopia\Query\Schema\MySQL as MySQLSchema;
+
+/**
+ * Database adapter for MariaDB, extending the base SQL adapter with MariaDB-specific features.
+ */
+class MariaDB extends SQL implements Feature\ConnectionId, Feature\SchemaAttributes, Feature\SchemaIndexes, Feature\Spatial, Feature\Timeouts
{
/**
- * Create Database
+ * Get the list of capabilities supported by the MariaDB adapter.
+ *
+ * @return array
+ */
+ public function capabilities(): array
+ {
+ return array_merge(parent::capabilities(), [
+ Capability::IntegerBooleans,
+ Capability::NumericCasting,
+ Capability::AlterLock,
+ Capability::JSONOverlaps,
+ Capability::FulltextWildcard,
+ Capability::PCRE,
+ Capability::SpatialIndexOrder,
+ Capability::OptionalSpatial,
+ Capability::Upserts,
+ Capability::UpsertOnUniqueIndex,
+ Capability::UnsignedBigInt,
+ ]);
+ }
+
+ /**
+ * Check whether the adapter supports storing non-UTF characters.
*
- * @param string $name
* @return bool
- * @throws Exception
- * @throws PDOException
*/
- public function create(string $name): bool
+ public function getSupportNonUtfCharacters(): bool
{
- $name = $this->filter($name);
+ return true;
+ }
- if ($this->exists($name)) {
- return true;
- }
+ /**
+ * Get the current database connection ID.
+ *
+ * @return string
+ */
+ public function getConnectionId(): string
+ {
+ $result = $this->createBuilder()->fromNone()->selectRaw('CONNECTION_ID()')->build();
+ $stmt = $this->getPDO()->query($result->query);
- $sql = "CREATE DATABASE `{$name}` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;";
+ if ($stmt === false) {
+ return '';
+ }
- $sql = $this->trigger(Database::EVENT_DATABASE_CREATE, $sql);
+ $col = $stmt->fetchColumn();
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return \is_scalar($col) ? (string) $col : '';
}
/**
- * Delete Database
+ * Create Database
*
- * @param string $name
- * @return bool
* @throws Exception
* @throws PDOException
*/
- public function delete(string $name): bool
+ public function create(string $name): bool
{
$name = $this->filter($name);
- $sql = "DROP DATABASE `{$name}`;";
+ if ($this->exists($name)) {
+ return true;
+ }
- $sql = $this->trigger(Database::EVENT_DATABASE_DELETE, $sql);
+ $result = $this->createSchemaBuilder()->createDatabase($name);
+ $sql = $result->query;
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return $this->executeStatement($sql, Event::DatabaseCreate);
}
/**
* Create Collection
*
- * @param string $name
- * @param array $attributes
- * @param array $indexes
- * @return bool
+ * @param array $attributes
+ * @param array $indexes
+ *
* @throws Exception
* @throws PDOException
*/
public function createCollection(string $name, array $attributes = [], array $indexes = []): bool
{
$id = $this->filter($name);
+ $schema = $this->createSchemaBuilder();
+ $sharedTables = $this->sharedTables;
- /** @var array $attributeStrings */
- $attributeStrings = [];
-
- /** @var array $indexStrings */
- $indexStrings = [];
-
+ // Pre-build attribute hash for array lookups during index construction
$hash = [];
-
- foreach ($attributes as $key => $attribute) {
- $attrId = $this->filter($attribute->getId());
+ foreach ($attributes as $attribute) {
+ $attrId = $this->filter($attribute->key);
$hash[$attrId] = $attribute;
+ }
- $attrType = $this->getSQLType(
- $attribute->getAttribute('type'),
- $attribute->getAttribute('size', 0),
- $attribute->getAttribute('signed', true),
- $attribute->getAttribute('array', false),
- $attribute->getAttribute('required', false)
- );
+ $table = $schema->table($this->getSQLTableRaw($id));
+ $table->id(Storage::SEQUENCE);
+ $table->string(Storage::UID, 255);
+ $table->datetime(Storage::CREATED_AT, 3)->nullable()->default(null);
+ $table->datetime(Storage::UPDATED_AT, 3)->nullable()->default(null);
+ $table->mediumText(Storage::PERMISSIONS)->nullable()->default(null);
+ $table->rawColumn('`'.Storage::VERSION.'` INT(11) UNSIGNED DEFAULT 1');
+
+ foreach ($attributes as $attribute) {
+ $attrId = $this->filter($attribute->key);
- // Ignore relationships with virtual attributes
- if ($attribute->getAttribute('type') === Database::VAR_RELATIONSHIP) {
- $options = $attribute->getAttribute('options', []);
+ if ($attribute->type === ColumnType::Relationship) {
+ $options = $attribute->options ?? [];
$relationType = $options['relationType'] ?? null;
$twoWay = $options['twoWay'] ?? false;
$side = $options['side'] ?? null;
if (
- $relationType === Database::RELATION_MANY_TO_MANY
- || ($relationType === Database::RELATION_ONE_TO_ONE && !$twoWay && $side === Database::RELATION_SIDE_CHILD)
- || ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT)
- || ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD)
+ $relationType === RelationType::ManyToMany->value
+ || ($relationType === RelationType::OneToOne->value && ! $twoWay && $side === RelationSide::Child->value)
+ || ($relationType === RelationType::OneToMany->value && $side === RelationSide::Parent->value)
+ || ($relationType === RelationType::ManyToOne->value && $side === RelationSide::Child->value)
) {
continue;
}
}
- $attributeStrings[$key] = "`{$attrId}` {$attrType}, ";
+ $attrType = $this->getSQLType(
+ $attribute->type,
+ $attribute->size,
+ $attribute->signed,
+ $attribute->array,
+ $attribute->required
+ );
+ $table->rawColumn("`{$attrId}` {$attrType}");
}
- foreach ($indexes as $key => $index) {
- $indexId = $this->filter($index->getId());
- $indexType = $index->getAttribute('type');
+ foreach ($indexes as $index) {
+ $indexId = $this->filter($index->key);
+ $indexType = $index->type;
+ $indexAttributes = $index->attributes;
+
+ $regularColumns = [];
+ $indexLengths = [];
+ $indexOrders = [];
+ $rawCastColumns = [];
- $indexAttributes = $index->getAttribute('attributes');
foreach ($indexAttributes as $nested => $attribute) {
- $indexLength = $index->getAttribute('lengths')[$nested] ?? '';
- $indexLength = (empty($indexLength)) ? '' : '(' . (int)$indexLength . ')';
- $indexOrder = $index->getAttribute('orders')[$nested] ?? '';
- if ($indexType === Database::INDEX_SPATIAL && !$this->getSupportForSpatialIndexOrder() && !empty($indexOrder)) {
+ $indexLength = $index->lengths[$nested] ?? '';
+ $indexOrder = Index::direction($index->orders[$nested] ?? null);
+
+ if ($indexType === IndexType::Spatial && ! $this->supports(Capability::SpatialIndexOrder) && ! empty($indexOrder)) {
throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.');
}
- $indexAttribute = $this->getInternalKeyForAttribute($attribute);
- $indexAttribute = $this->filter($indexAttribute);
- if ($indexType === Database::INDEX_FULLTEXT) {
+ $indexAttribute = $this->filter($this->getInternalKeyForAttribute($attribute));
+
+ if ($indexType === IndexType::Fulltext) {
$indexOrder = '';
}
- $indexAttributes[$nested] = "`{$indexAttribute}`{$indexLength} {$indexOrder}";
-
- if (!empty($hash[$indexAttribute]['array']) && $this->getSupportForCastIndexArray()) {
- $indexAttributes[$nested] = '(CAST(`' . $indexAttribute . '` AS char(' . Database::MAX_ARRAY_INDEX_LENGTH . ') ARRAY))';
+ if (! empty($hash[$indexAttribute]->array) && $this->supports(Capability::CastIndexArray)) {
+ $rawCastColumns[] = '(CAST(`'.$indexAttribute.'` AS char('.Database::MAX_ARRAY_INDEX_LENGTH.') ARRAY))';
+ } else {
+ $regularColumns[] = $indexAttribute;
+ if (! empty($indexLength)) {
+ $indexLengths[$indexAttribute] = (int) $indexLength;
+ }
+ if (! empty($indexOrder)) {
+ $indexOrders[$indexAttribute] = $indexOrder;
+ }
}
}
- $indexAttributes = \implode(", ", $indexAttributes);
-
- if ($this->sharedTables && $indexType !== Database::INDEX_FULLTEXT && $indexType !== Database::INDEX_SPATIAL) {
- // Add tenant as first index column for best performance
- $indexAttributes = "_tenant, {$indexAttributes}";
+ if ($sharedTables && $indexType !== IndexType::Fulltext && $indexType !== IndexType::Spatial) {
+ \array_unshift($regularColumns, Storage::TENANT);
}
- $indexStrings[$key] = "{$indexType} `{$indexId}` ({$indexAttributes}),";
+ $table->addIndex(
+ $indexId,
+ $regularColumns,
+ $indexType,
+ $indexLengths,
+ $indexOrders,
+ rawColumns: $rawCastColumns,
+ );
}
- $collection = "
- CREATE TABLE {$this->getSQLTable($id)} (
- _id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
- _uid VARCHAR(255) NOT NULL,
- _createdAt DATETIME(3) DEFAULT NULL,
- _updatedAt DATETIME(3) DEFAULT NULL,
- _permissions MEDIUMTEXT DEFAULT NULL,
- PRIMARY KEY (_id),
- " . \implode(' ', $attributeStrings) . "
- " . \implode(' ', $indexStrings) . "
- ";
-
- if ($this->sharedTables) {
- $collection .= "
- _tenant INT(11) UNSIGNED DEFAULT NULL,
- UNIQUE KEY _uid (_uid, _tenant),
- KEY _created_at (_tenant, _createdAt),
- KEY _updated_at (_tenant, _updatedAt),
- KEY _tenant_id (_tenant, _id)
- ";
+ if ($sharedTables) {
+ $table->rawColumn(Storage::TENANT.' INT(11) UNSIGNED DEFAULT NULL');
+ $table->uniqueIndex([Storage::UID, Storage::TENANT], Storage::UID);
+ $table->index([Storage::TENANT, Storage::CREATED_AT], Storage::INDEX_CREATED_AT);
+ $table->index([Storage::TENANT, Storage::UPDATED_AT], Storage::INDEX_UPDATED_AT);
+ $table->index([Storage::TENANT, Storage::SEQUENCE], Storage::INDEX_TENANT_ID);
} else {
- $collection .= "
- UNIQUE KEY _uid (_uid),
- KEY _created_at (_createdAt),
- KEY _updated_at (_updatedAt)
- ";
+ $table->uniqueIndex([Storage::UID], Storage::UID);
+ $table->index([Storage::CREATED_AT], Storage::INDEX_CREATED_AT);
+ $table->index([Storage::UPDATED_AT], Storage::INDEX_UPDATED_AT);
}
- $collection .= ")";
- $collection = $this->trigger(Database::EVENT_COLLECTION_CREATE, $collection);
-
- $permissions = "
- CREATE TABLE {$this->getSQLTable($id . '_perms')} (
- _id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
- _type VARCHAR(12) NOT NULL,
- _permission VARCHAR(255) NOT NULL,
- _document VARCHAR(255) NOT NULL,
- PRIMARY KEY (_id),
- ";
-
- if ($this->sharedTables) {
- $permissions .= "
- _tenant INT(11) UNSIGNED DEFAULT NULL,
- UNIQUE INDEX _index1 (_document, _tenant, _type, _permission),
- INDEX _permission (_tenant, _permission, _type)
- ";
+ $collectionResult = $table->create();
+ $collection = $collectionResult->query;
+
+ $permsTable = $schema->table($this->getSQLTableRaw(Storage::permissionsTable($id)));
+ $permsTable->id(Storage::SEQUENCE);
+ $permsTable->string(Storage::PERM_TYPE, 12);
+ $permsTable->string(Storage::PERM_PERMISSION, 255);
+ $permsTable->string(Storage::PERM_DOCUMENT, 255);
+
+ if ($sharedTables) {
+ $permsTable->integer(Storage::TENANT)->unsigned()->nullable()->default(null);
+ $permsTable->uniqueIndex([Storage::PERM_DOCUMENT, Storage::TENANT, Storage::PERM_TYPE, Storage::PERM_PERMISSION], Storage::INDEX_1);
+ $permsTable->index([Storage::TENANT, Storage::PERM_PERMISSION, Storage::PERM_TYPE], Storage::PERM_PERMISSION);
} else {
- $permissions .= "
- UNIQUE INDEX _index1 (_document, _type, _permission),
- INDEX _permission (_permission, _type)
- ";
+ $permsTable->uniqueIndex([Storage::PERM_DOCUMENT, Storage::PERM_TYPE, Storage::PERM_PERMISSION], Storage::INDEX_1);
+ $permsTable->index([Storage::PERM_PERMISSION, Storage::PERM_TYPE], Storage::PERM_PERMISSION);
}
- $permissions .= ")";
- $permissions = $this->trigger(Database::EVENT_COLLECTION_CREATE, $permissions);
+ $permsResult = $permsTable->create();
+ $permissions = $permsResult->query;
try {
- $this->getPDO()
- ->prepare($collection)
- ->execute();
-
- $this->getPDO()
- ->prepare($permissions)
- ->execute();
+ $this->executeStatement($collection, Event::CollectionCreate);
+ $this->executeStatement($permissions, Event::CollectionCreate);
} catch (PDOException $e) {
throw $this->processException($e);
}
@@ -229,97 +268,9 @@ public function createCollection(string $name, array $attributes = [], array $in
return true;
}
- /**
- * Get collection size on disk
- *
- * @param string $collection
- * @return int
- * @throws DatabaseException
- */
- public function getSizeOfCollectionOnDisk(string $collection): int
- {
- $collection = $this->filter($collection);
- $collection = $this->getNamespace() . '_' . $collection;
- $database = $this->getDatabase();
- $name = $database . '/' . $collection;
- $permissions = $database . '/' . $collection . '_perms';
-
- $collectionSize = $this->getPDO()->prepare("
- SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)
- FROM INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES
- WHERE NAME = :name
- ");
-
- $permissionsSize = $this->getPDO()->prepare("
- SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)
- FROM INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES
- WHERE NAME = :permissions
- ");
-
- $collectionSize->bindParam(':name', $name);
- $permissionsSize->bindParam(':permissions', $permissions);
-
- try {
- $collectionSize->execute();
- $permissionsSize->execute();
- $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn();
- } catch (PDOException $e) {
- throw new DatabaseException('Failed to get collection size: ' . $e->getMessage());
- }
-
- return $size;
- }
-
- /**
- * Get Collection Size of the raw data
- *
- * @param string $collection
- * @return int
- * @throws DatabaseException
- */
- public function getSizeOfCollection(string $collection): int
- {
- $collection = $this->filter($collection);
- $collection = $this->getNamespace() . '_' . $collection;
- $database = $this->getDatabase();
- $permissions = $collection . '_perms';
-
- // Both tables in one round trip. Keep the equality predicates: LIKE and IN are
- // not indexed here, they scan every table in the schema.
- $statement = $this->getPDO()->prepare("
- SELECT SUM(size) FROM (
- SELECT data_length + index_length AS size
- FROM INFORMATION_SCHEMA.TABLES
- WHERE table_name = :name AND
- table_schema = :database_name
- UNION ALL
- SELECT data_length + index_length AS size
- FROM INFORMATION_SCHEMA.TABLES
- WHERE table_name = :permissions AND
- table_schema = :database_permissions
- ) AS sizes
- ");
-
- $statement->bindParam(':name', $collection);
- $statement->bindParam(':permissions', $permissions);
- $statement->bindParam(':database_name', $database);
- $statement->bindParam(':database_permissions', $database);
-
- try {
- $statement->execute();
- $size = $statement->fetchColumn();
- } catch (PDOException $e) {
- throw new DatabaseException('Failed to get collection size: ' . $e->getMessage());
- }
-
- return (int) $size;
- }
-
/**
* Delete collection
*
- * @param string $id
- * @return bool
* @throws Exception
* @throws PDOException
*/
@@ -327,14 +278,14 @@ public function deleteCollection(string $id): bool
{
$id = $this->filter($id);
- $sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')};";
+ $schema = $this->createSchemaBuilder();
+ $mainResult = $schema->table($this->getSQLTableRaw($id))->drop();
+ $permsResult = $schema->table($this->getSQLTableRaw(Storage::permissionsTable($id)))->drop();
- $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql);
+ $sql = $mainResult->query.'; '.$permsResult->query;
try {
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return $this->executeStatement($sql, Event::CollectionDelete);
} catch (PDOException $e) {
throw $this->processException($e);
}
@@ -343,441 +294,264 @@ public function deleteCollection(string $id): bool
/**
* Analyze a collection updating it's metadata on the database engine
*
- * @param string $collection
- * @return bool
* @throws DatabaseException
*/
public function analyzeCollection(string $collection): bool
{
$name = $this->filter($collection);
- $sql = "ANALYZE TABLE {$this->getSQLTable($name)}";
+ $result = $this->createSchemaBuilder()->analyzeTable($this->getSQLTableRaw($name));
+ $sql = $result->query;
- $stmt = $this->getPDO()->prepare($sql);
- return $stmt->execute();
+ return $this->executeStatement($sql, Event::CollectionUpdate);
}
/**
- * Get Schema Attributes
+ * Get collection size on disk
*
- * @param string $collection
- * @return array
* @throws DatabaseException
*/
- public function getSchemaAttributes(string $collection): array
+ public function getSizeOfCollectionOnDisk(string $collection): int
{
- $schema = $this->getDatabase();
- $collection = $this->getNamespace().'_'.$this->filter($collection);
+ $collection = $this->filter($collection);
+ $collection = $this->getNamespace().'_'.$collection;
+ $database = $this->getDatabase();
+ $name = $database.'/'.$collection;
+ $permissions = $database.'/'.Storage::permissionsTable($collection);
- try {
- $stmt = $this->getPDO()->prepare('
- SELECT
- COLUMN_NAME as _id,
- COLUMN_DEFAULT as columnDefault,
- IS_NULLABLE as isNullable,
- DATA_TYPE as dataType,
- CHARACTER_MAXIMUM_LENGTH as characterMaximumLength,
- NUMERIC_PRECISION as numericPrecision,
- NUMERIC_SCALE as numericScale,
- DATETIME_PRECISION as datetimePrecision,
- COLUMN_TYPE as columnType,
- COLUMN_KEY as columnKey,
- EXTRA as extra
- FROM INFORMATION_SCHEMA.COLUMNS
- WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
- ');
- $stmt->bindParam(':schema', $schema);
- $stmt->bindParam(':table', $collection);
- $stmt->execute();
- $results = $stmt->fetchAll();
- $stmt->closeCursor();
+ $builder = $this->createBuilder();
- foreach ($results as $index => $document) {
- $document['$id'] = $document['_id'];
- unset($document['_id']);
+ $collectionResult = $builder
+ ->from('INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES')
+ ->selectRaw('SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)')
+ ->filter([BaseQuery::equal('NAME', [$name])])
+ ->build();
- $results[$index] = new Document($document);
- }
+ $permissionsResult = $builder->reset()
+ ->from('INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES')
+ ->selectRaw('SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)')
+ ->filter([BaseQuery::equal('NAME', [$permissions])])
+ ->build();
- return $results;
+ $collectionSize = $this->executeResult($collectionResult, Event::CollectionRead);
+ $permissionsSize = $this->executeResult($permissionsResult, Event::CollectionRead);
- } catch (PDOException $e) {
- throw new DatabaseException('Failed to get schema attributes', $e->getCode(), $e);
+ foreach ($collectionResult->bindings as $i => $v) {
+ $collectionSize->bindValue($i + 1, $v);
}
- }
-
- /**
- * Update Attribute
- *
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param int $size
- * @param bool $signed
- * @param bool $array
- * @param string|null $newKey
- * @param bool $required
- * @return bool
- * @throws DatabaseException
- */
- public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool
- {
- $name = $this->filter($collection);
- $id = $this->filter($id);
- $newKey = empty($newKey) ? null : $this->filter($newKey);
- $type = $this->getSQLType($type, $size, $signed, $array, $required);
- if (!empty($newKey)) {
- $sql = "ALTER TABLE {$this->getSQLTable($name)} CHANGE COLUMN `{$id}` `{$newKey}` {$type};";
- } else {
- $sql = "ALTER TABLE {$this->getSQLTable($name)} MODIFY `{$id}` {$type};";
+ foreach ($permissionsResult->bindings as $i => $v) {
+ $permissionsSize->bindValue($i + 1, $v);
}
- $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql);
-
try {
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ $this->execute($collectionSize);
+ $this->execute($permissionsSize);
+ $collSizeVal = $collectionSize->fetchColumn();
+ $permSizeVal = $permissionsSize->fetchColumn();
+ $size = (int) (\is_numeric($collSizeVal) ? $collSizeVal : 0) + (int) (\is_numeric($permSizeVal) ? $permSizeVal : 0);
} catch (PDOException $e) {
- throw $this->processException($e);
+ throw new DatabaseException('Failed to get collection size: '.$e->getMessage());
}
+
+ return $size;
}
/**
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param string $relatedCollection
- * @param bool $twoWay
- * @param string $twoWayKey
- * @return bool
+ * Get Collection Size of the raw data
+ *
* @throws DatabaseException
*/
- public function createRelationship(
- string $collection,
- string $relatedCollection,
- string $type,
- bool $twoWay = false,
- string $id = '',
- string $twoWayKey = ''
- ): bool {
- $name = $this->filter($collection);
- $relatedName = $this->filter($relatedCollection);
- $table = $this->getSQLTable($name);
- $relatedTable = $this->getSQLTable($relatedName);
- $id = $this->filter($id);
- $twoWayKey = $this->filter($twoWayKey);
- $sqlType = $this->getSQLType(Database::VAR_RELATIONSHIP, 0, false, false, false);
-
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
- $sql = "ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL;";
+ public function getSizeOfCollection(string $collection): int
+ {
+ $collection = $this->filter($collection);
+ $collection = $this->getNamespace().'_'.$collection;
+ $database = $this->getDatabase();
+ $permissions = Storage::permissionsTable($collection);
+
+ $result = $this->createBuilder()
+ ->fromNone()
+ ->selectRaw(
+ 'SUM(size) FROM (
+ SELECT data_length + index_length AS size
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE table_name = ? AND
+ table_schema = ?
+ UNION ALL
+ SELECT data_length + index_length AS size
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE table_name = ? AND
+ table_schema = ?
+ ) AS sizes',
+ [$collection, $database, $permissions, $database]
+ )
+ ->build();
+
+ $statement = $this->executeResult($result, Event::CollectionRead);
- if ($twoWay) {
- $sql .= "ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL;";
- }
- break;
- case Database::RELATION_ONE_TO_MANY:
- $sql = "ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL;";
- break;
- case Database::RELATION_MANY_TO_ONE:
- $sql = "ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL;";
- break;
- case Database::RELATION_MANY_TO_MANY:
- return true;
- default:
- throw new DatabaseException('Invalid relationship type');
+ try {
+ $this->execute($statement);
+ $size = $statement->fetchColumn();
+ } catch (PDOException $e) {
+ throw new DatabaseException('Failed to get collection size: '.$e->getMessage());
}
- $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql);
-
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return (int) (\is_numeric($size) ? $size : 0);
}
/**
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $key
- * @param string $twoWayKey
- * @param string $side
- * @param string|null $newKey
- * @param string|null $newTwoWayKey
+ * Create a new attribute column, handling spatial types with MariaDB-specific syntax.
+ *
+ * @param string $collection The collection name
+ * @param Attribute $attribute The attribute definition
* @return bool
+ *
* @throws DatabaseException
*/
- public function updateRelationship(
- string $collection,
- string $relatedCollection,
- string $type,
- bool $twoWay,
- string $key,
- string $twoWayKey,
- string $side,
- ?string $newKey = null,
- ?string $newTwoWayKey = null,
- ): bool {
- $name = $this->filter($collection);
- $relatedName = $this->filter($relatedCollection);
- $table = $this->getSQLTable($name);
- $relatedTable = $this->getSQLTable($relatedName);
- $key = $this->filter($key);
- $twoWayKey = $this->filter($twoWayKey);
-
- if (!\is_null($newKey)) {
- $newKey = $this->filter($newKey);
- }
- if (!\is_null($newTwoWayKey)) {
- $newTwoWayKey = $this->filter($newTwoWayKey);
- }
-
- $sql = '';
-
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
- if ($key !== $newKey) {
- $sql = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`;";
- }
- if ($twoWay && $twoWayKey !== $newTwoWayKey) {
- $sql .= "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;";
- }
- break;
- case Database::RELATION_ONE_TO_MANY:
- if ($side === Database::RELATION_SIDE_PARENT) {
- if ($twoWayKey !== $newTwoWayKey) {
- $sql = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;";
- }
- } else {
- if ($key !== $newKey) {
- $sql = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`;";
- }
- }
- break;
- case Database::RELATION_MANY_TO_ONE:
- if ($side === Database::RELATION_SIDE_CHILD) {
- if ($twoWayKey !== $newTwoWayKey) {
- $sql = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;";
- }
- } else {
- if ($key !== $newKey) {
- $sql = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`;";
- }
- }
- break;
- case Database::RELATION_MANY_TO_MANY:
- $metadataCollection = new Document(['$id' => Database::METADATA]);
- $collection = $this->getDocument($metadataCollection, $collection);
- $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection);
-
- $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence());
+ public function createAttribute(string $collection, Attribute $attribute): bool
+ {
+ if (\in_array($attribute->type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon])) {
+ $id = $this->filter($attribute->key);
+ $table = $this->getSQLTableRaw($collection);
+ $sqlType = $this->getSpatialSQLType($attribute->type->value, $attribute->required);
+ $sql = "ALTER TABLE {$table} ADD COLUMN {$this->quote($id)} {$sqlType}";
+ $lockType = $this->getLockType();
+ if (! empty($lockType)) {
+ $sql .= ' '.$lockType;
+ }
- if (!\is_null($newKey)) {
- $sql = "ALTER TABLE {$junction} RENAME COLUMN `{$key}` TO `{$newKey}`;";
- }
- if ($twoWay && !\is_null($newTwoWayKey)) {
- $sql .= "ALTER TABLE {$junction} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`;";
- }
- break;
- default:
- throw new DatabaseException('Invalid relationship type');
- }
+ try {
+ $ok = $this->executeStatement($sql, Event::AttributeCreate);
+ $this->invalidateSpatialAttributesCache($collection);
- if (empty($sql)) {
- return true;
+ return $ok;
+ } catch (PDOException $e) {
+ throw $this->processException($e);
+ }
}
- $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql);
-
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return parent::createAttribute($collection, $attribute);
}
/**
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $key
- * @param string $twoWayKey
- * @param string $side
- * @return bool
+ * Update Attribute
+ *
* @throws DatabaseException
*/
- public function deleteRelationship(
- string $collection,
- string $relatedCollection,
- string $type,
- bool $twoWay,
- string $key,
- string $twoWayKey,
- string $side
- ): bool {
+ public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool
+ {
$name = $this->filter($collection);
- $relatedName = $this->filter($relatedCollection);
- $table = $this->getSQLTable($name);
- $relatedTable = $this->getSQLTable($relatedName);
- $key = $this->filter($key);
- $twoWayKey = $this->filter($twoWayKey);
-
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
- if ($side === Database::RELATION_SIDE_PARENT) {
- $sql = "ALTER TABLE {$table} DROP COLUMN `{$key}`;";
- if ($twoWay) {
- $sql .= "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;";
- }
- } elseif ($side === Database::RELATION_SIDE_CHILD) {
- $sql = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;";
- if ($twoWay) {
- $sql .= "ALTER TABLE {$table} DROP COLUMN `{$key}`;";
- }
- }
- break;
- case Database::RELATION_ONE_TO_MANY:
- if ($side === Database::RELATION_SIDE_PARENT) {
- $sql = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;";
- } else {
- $sql = "ALTER TABLE {$table} DROP COLUMN `{$key}`;";
- }
- break;
- case Database::RELATION_MANY_TO_ONE:
- if ($side === Database::RELATION_SIDE_PARENT) {
- $sql = "ALTER TABLE {$table} DROP COLUMN `{$key}`;";
- } else {
- $sql = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`;";
- }
- break;
- case Database::RELATION_MANY_TO_MANY:
- $metadataCollection = new Document(['$id' => Database::METADATA]);
- $collection = $this->getDocument($metadataCollection, $collection);
- $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection);
-
- $junction = $side === Database::RELATION_SIDE_PARENT
- ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence())
- : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence());
-
- $perms = $side === Database::RELATION_SIDE_PARENT
- ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() . '_perms')
- : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence() . '_perms');
-
- $sql = "DROP TABLE {$junction}; DROP TABLE {$perms}";
- break;
- default:
- throw new DatabaseException('Invalid relationship type');
- }
+ $id = $this->filter($attribute->key);
+ $newKey = empty($newKey) ? null : $this->filter($newKey);
+ $sqlType = $this->getSQLType($attribute->type, $attribute->size, $attribute->signed, $attribute->array, $attribute->required);
+ $schema = $this->createSchemaBuilder();
+ $tableRaw = $this->getSQLTableRaw($name);
- if (empty($sql)) {
- return true;
+ if (! empty($newKey)) {
+ $result = $schema->changeColumn($tableRaw, $id, $newKey, $sqlType);
+ } else {
+ $result = $schema->modifyColumn($tableRaw, $id, $sqlType);
}
- $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql);
-
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
- }
-
- /**
- * Rename Index
- *
- * @param string $collection
- * @param string $old
- * @param string $new
- * @return bool
- * @throws Exception
- */
- public function renameIndex(string $collection, string $old, string $new): bool
- {
- $collection = $this->filter($collection);
- $old = $this->filter($old);
- $new = $this->filter($new);
-
- $sql = "ALTER TABLE {$this->getSQLTable($collection)} RENAME INDEX `{$old}` TO `{$new}`;";
+ $sql = $result->query;
- $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql);
+ try {
+ $ok = $this->executeStatement($sql, Event::AttributeUpdate);
+ $this->invalidateSpatialAttributesCache($collection);
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return $ok;
+ } catch (PDOException $e) {
+ throw $this->processException($e);
+ }
}
/**
* Create Index
*
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param array $attributes
- * @param array $lengths
- * @param array $orders
- * @param array $indexAttributeTypes
- * @return bool
+ * @param array $indexAttributeTypes
+ * @param array $collation
+ *
* @throws DatabaseException
*/
- public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool
+ public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool
{
- $metadataCollection = new Document(['$id' => Database::METADATA]);
+ $metadataCollection = new Document([Document::ID => Database::METADATA]);
$collection = $this->getDocument($metadataCollection, $collection);
if ($collection->isEmpty()) {
throw new NotFoundException('Collection not found');
}
- /**
- * We do not have sequence's added to list, since we check only for array field
- */
- $collectionAttributes = \json_decode($collection->getAttribute('attributes', []), true);
+ $rawAttrs = $collection->getAttribute('attributes', []);
+ /** @var array> $collectionAttributes */
+ $collectionAttributes = \is_string($rawAttrs) ? (\json_decode($rawAttrs, true) ?? []) : [];
+ $id = $this->filter($index->key);
+ $type = $index->type;
+ $attributes = $index->attributes;
+ $lengths = $index->lengths;
+ $orders = $index->orders;
- $id = $this->filter($id);
+ $schema = $this->createSchemaBuilder();
+ $tableName = $this->getSQLTableRaw($collection->getId());
+
+ // Build column lists, separating regular columns from raw CAST ARRAY expressions
+ $schemaColumns = [];
+ $schemaLengths = [];
+ $schemaOrders = [];
+ $rawExpressions = [];
foreach ($attributes as $i => $attr) {
$attribute = null;
foreach ($collectionAttributes as $collectionAttribute) {
- if (\strtolower($collectionAttribute['$id']) === \strtolower($attr)) {
+ $collAttrId = $collectionAttribute[Document::ID] ?? '';
+ if (\strtolower(\is_string($collAttrId) ? $collAttrId : '') === \strtolower($attr)) {
$attribute = $collectionAttribute;
break;
}
}
- $order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i];
- $length = empty($lengths[$i]) ? '' : '(' . (int)$lengths[$i] . ')';
-
- $attr = $this->getInternalKeyForAttribute($attr);
- $attr = $this->filter($attr);
-
- $attributes[$i] = "`{$attr}`{$length} {$order}";
+ $attr = $this->filter($this->getInternalKeyForAttribute($attr));
+ $order = $type === IndexType::Fulltext ? '' : Index::direction($orders[$i] ?? null);
+ $length = empty($lengths[$i]) ? 0 : (int) $lengths[$i];
- if ($this->getSupportForCastIndexArray() && !empty($attribute['array'])) {
- $attributes[$i] = '(CAST(`' . $attr . '` AS char(' . Database::MAX_ARRAY_INDEX_LENGTH . ') ARRAY))';
+ if ($this->supports(Capability::CastIndexArray) && ! empty($attribute['array'])) {
+ $rawExpressions[] = '(CAST(`'.$attr.'` AS char('.Database::MAX_ARRAY_INDEX_LENGTH.') ARRAY))';
+ } else {
+ $schemaColumns[] = $attr;
+ if ($length > 0) {
+ $schemaLengths[$attr] = $length;
+ }
+ if (! empty($order)) {
+ $schemaOrders[$attr] = $order;
+ }
}
}
- $sqlType = match ($type) {
- Database::INDEX_KEY => 'INDEX',
- Database::INDEX_UNIQUE => 'UNIQUE INDEX',
- Database::INDEX_FULLTEXT => 'FULLTEXT INDEX',
- Database::INDEX_SPATIAL => 'SPATIAL INDEX',
- default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT . ', ' . Database::INDEX_SPATIAL),
- };
-
- $attributes = \implode(', ', $attributes);
-
- if ($this->sharedTables && $type !== Database::INDEX_FULLTEXT && $type !== Database::INDEX_SPATIAL) {
- // Add tenant as first index column for best performance
- $attributes = "_tenant, {$attributes}";
+ if ($this->sharedTables && $type !== IndexType::Fulltext && $type !== IndexType::Spatial) {
+ \array_unshift($schemaColumns, Storage::TENANT);
}
- $sql = "CREATE {$sqlType} `{$id}` ON {$this->getSQLTable($collection->getId())} ({$attributes})";
- $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql);
+ $unique = $type === IndexType::Unique;
+ $schemaType = match ($type) {
+ IndexType::Key, IndexType::Unique => '',
+ IndexType::Fulltext => 'fulltext',
+ IndexType::Spatial => 'spatial',
+ default => throw new DatabaseException('Unknown index type: '.$type->value.'. Must be one of '.IndexType::Key->value.', '.IndexType::Unique->value.', '.IndexType::Fulltext->value.', '.IndexType::Spatial->value),
+ };
+
+ $result = $schema->createIndex(
+ $tableName,
+ $id,
+ $schemaColumns,
+ unique: $unique,
+ type: $schemaType,
+ lengths: $schemaLengths,
+ orders: $schemaOrders,
+ rawColumns: $rawExpressions,
+ );
+ $sql = $result->query;
try {
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return $this->executeStatement($sql, Event::IndexCreate);
} catch (PDOException $e) {
throw $this->processException($e);
}
@@ -786,9 +560,6 @@ public function createIndex(string $collection, string $id, string $type, array
/**
* Delete Index
*
- * @param string $collection
- * @param string $id
- * @return bool
* @throws Exception
* @throws PDOException
*/
@@ -797,16 +568,15 @@ public function deleteIndex(string $collection, string $id): bool
$name = $this->filter($collection);
$id = $this->filter($id);
- $sql = "ALTER TABLE {$this->getSQLTable($name)} DROP INDEX `{$id}`;";
+ $schema = $this->createSchemaBuilder();
+ $result = $schema->dropIndex($this->getSQLTableRaw($name), $id);
- $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql);
+ $sql = $result->query;
try {
- return $this->getPDO()
- ->prepare($sql)
- ->execute();
+ return $this->executeStatement($sql, Event::IndexDelete);
} catch (PDOException $e) {
- if ($e->getCode() === "42000" && $e->errorInfo[1] === 1091) {
+ if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) {
return true;
}
@@ -814,12 +584,26 @@ public function deleteIndex(string $collection, string $id): bool
}
}
+ /**
+ * Rename Index
+ *
+ * @throws Exception
+ */
+ public function renameIndex(string $collection, string $old, string $new): bool
+ {
+ $collection = $this->filter($collection);
+ $old = $this->filter($old);
+ $new = $this->filter($new);
+
+ $result = $this->createSchemaBuilder()->renameIndex($this->getSQLTableRaw($collection), $old, $new);
+ $sql = $result->query;
+
+ return $this->executeStatement($sql, Event::IndexRename);
+ }
+
/**
* Create Document
*
- * @param Document $collection
- * @param Document $document
- * @return Document
* @throws Exception
* @throws PDOException
* @throws DuplicateException
@@ -828,130 +612,83 @@ public function deleteIndex(string $collection, string $id): bool
public function createDocument(Document $collection, Document $document): Document
{
try {
+ $this->syncWriteHooks();
+
$spatialAttributes = $this->getSpatialAttributes($collection);
$collection = $collection->getId();
$attributes = $document->getAttributes();
- $attributes['_createdAt'] = $document->getCreatedAt();
- $attributes['_updatedAt'] = $document->getUpdatedAt();
- $attributes['_permissions'] = \json_encode($document->getPermissions());
-
- if ($this->sharedTables) {
- $attributes['_tenant'] = $document->getTenant();
+ $attributes[Storage::CREATED_AT] = $document->getCreatedAt();
+ $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt();
+ $attributes[Storage::PERMISSIONS] = \json_encode($document->getPermissions());
+ $version = $document->getVersion();
+ if ($version !== null) {
+ $attributes[Storage::VERSION] = $version;
}
$name = $this->filter($collection);
- $columns = '';
- $columnNames = '';
- /**
- * Insert Attributes
- */
- $bindIndex = 0;
- foreach ($attributes as $attribute => $value) {
- $column = $this->filter($attribute);
- $bindKey = 'key_' . $bindIndex;
- $columns .= "`{$column}`, ";
- if (in_array($attribute, $spatialAttributes)) {
- $columnNames .= $this->getSpatialGeomFromText(':' . $bindKey) . ", ";
- } else {
- $columnNames .= ':' . $bindKey . ', ';
- }
- $bindIndex++;
- }
-
- // Insert internal ID if set
- if (!empty($document->getSequence())) {
- $bindKey = '_id';
- $columns .= "_id, ";
- $columnNames .= ':' . $bindKey . ', ';
- }
-
- $sql = "
- INSERT INTO {$this->getSQLTable($name)} ({$columns} _uid)
- VALUES ({$columnNames} :_uid)
- ";
-
- $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql);
+ // Build document INSERT using query builder
+ // Spatial columns use insertColumnExpression() for ST_GeomFromText() wrapping
+ $builder = $this->createBuilder()->into($this->getSQLTableRaw($name));
+ $row = [Storage::UID => $document->getId()];
- $stmt = $this->getPDO()->prepare($sql);
-
- $stmt->bindValue(':_uid', $document->getId());
-
- if (!empty($document->getSequence())) {
- $stmt->bindValue(':_id', $document->getSequence());
+ if (! empty($document->getSequence())) {
+ $row[Storage::SEQUENCE] = $document->getSequence();
}
- $attributeIndex = 0;
- foreach ($attributes as $value) {
- if (\is_array($value)) {
- $value = \json_encode($value);
- }
+ $spatialMap = \array_fill_keys($spatialAttributes, true);
- $bindKey = 'key_' . $attributeIndex;
- $attribute = $this->filter($attribute);
- $value = (\is_bool($value)) ? (int)$value : $value;
- $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value));
- $attributeIndex++;
- }
+ foreach ($attributes as $attr => $value) {
+ $column = $this->filter($attr);
- $permissions = [];
- foreach (Database::PERMISSIONS as $type) {
- foreach ($document->getPermissionsByType($type) as $permission) {
- $tenantBind = $this->sharedTables ? ", :_tenant" : '';
- $permission = \str_replace('"', '', $permission);
- $permission = "('{$type}', '{$permission}', :_uid {$tenantBind})";
- $permissions[] = $permission;
+ if (isset($spatialMap[$attr]) || $this->isSpatialWkt($value)) {
+ $value = $this->encodeSpatialWriteValue($value);
+ $value = (\is_bool($value)) ? (int) $value : $value;
+ $row[$column] = $value;
+ $builder->insertColumnExpression($column, $this->getSpatialGeomFromText('?'));
+ } else {
+ if (\is_array($value)) {
+ $value = \json_encode($value);
+ }
+ $value = (\is_bool($value)) ? (int) $value : $value;
+ $row[$column] = $value;
}
}
- if (!empty($permissions)) {
- $tenantColumn = $this->sharedTables ? ', _tenant' : '';
- $permissions = \implode(', ', $permissions);
-
- $sqlPermissions = "
- INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn})
- VALUES {$permissions};
- ";
+ $row = $this->decorateRow($row, $this->documentMetadata($document));
+ $builder->set($row);
+ $result = $builder->insert();
+ $stmt = $this->executeResult($result, Event::DocumentCreate);
- $stmtPermissions = $this->getPDO()->prepare($sqlPermissions);
- $stmtPermissions->bindValue(':_uid', $document->getId());
- if ($this->sharedTables) {
- $stmtPermissions->bindValue(':_tenant', $document->getTenant());
- }
- }
+ $this->execute($stmt);
- $stmt->execute();
+ $document[Document::SEQUENCE] = $this->getPDO()->lastInsertId();
- $document['$sequence'] = $this->pdo->lastInsertId();
-
- if (empty($document['$sequence'])) {
- throw new DatabaseException('Error creating document empty "$sequence"');
+ if (empty($document[Document::SEQUENCE])) {
+ throw new DatabaseException('Error creating document empty "'.Document::SEQUENCE.'"');
}
- if (isset($stmtPermissions)) {
- try {
- $stmtPermissions->execute();
- } catch (PDOException $e) {
- $isOrphanedPermission = $e->getCode() === '23000'
- && isset($e->errorInfo[1])
- && $e->errorInfo[1] === 1062
- && \str_contains($e->getMessage(), '_index1');
-
- if (!$isOrphanedPermission) {
- throw $e;
- }
+ $ctx = $this->buildWriteContext($name);
+ try {
+ $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $ctx));
+ } catch (PDOException $e) {
+ $isOrphanedPermission = $e->getCode() === '23000'
+ && isset($e->errorInfo[1])
+ && $e->errorInfo[1] === 1062
+ && \str_contains($e->getMessage(), Storage::INDEX_1);
+
+ if (! $isOrphanedPermission) {
+ throw $e;
+ }
- // Clean up orphaned permissions from a previous failed delete, then retry
- $sql = "DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)}";
- $cleanup = $this->getPDO()->prepare($sql);
- $cleanup->bindValue(':_uid', $document->getId());
- if ($this->sharedTables) {
- $cleanup->bindValue(':_tenant', $document->getTenant());
- }
- $cleanup->execute();
+ // Clean up orphaned permissions from a previous failed delete, then retry
+ $cleanupBuilder = $this->newBuilder(Storage::permissionsTable($name));
+ $cleanupBuilder->filter([BaseQuery::equal(Storage::PERM_DOCUMENT, [$document->getId()])]);
+ $cleanupResult = $cleanupBuilder->delete();
+ $cleanupStmt = $this->executeResult($cleanupResult, Event::PermissionsDelete);
+ $this->execute($cleanupStmt);
- $stmtPermissions->execute();
- }
+ $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $ctx));
}
} catch (PDOException $e) {
throw $this->processException($e);
@@ -963,11 +700,6 @@ public function createDocument(Document $collection, Document $document): Docume
/**
* Update Document
*
- * @param Document $collection
- * @param string $id
- * @param Document $document
- * @param bool $skipPermissions
- * @return Document
* @throws Exception
* @throws PDOException
* @throws DuplicateException
@@ -976,142 +708,68 @@ public function createDocument(Document $collection, Document $document): Docume
public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document
{
try {
+ $this->syncWriteHooks();
+
$spatialAttributes = $this->getSpatialAttributes($collection);
$collection = $collection->getId();
$attributes = $document->getAttributes();
- $attributes['_createdAt'] = $document->getCreatedAt();
- $attributes['_updatedAt'] = $document->getUpdatedAt();
- $attributes['_permissions'] = json_encode($document->getPermissions());
- $attributes['_uid'] = $document->getId();
-
- $name = $this->filter($collection);
- $columns = '';
-
- if (!$skipPermissions) {
- $newUid = $document->offsetExists('$id') ? $document->getId() : $id;
-
- $sql = "
- DELETE FROM {$this->getSQLTable($name . '_perms')}
- WHERE _document = :_uid
- {$this->getTenantQuery($collection)}
- ";
-
- $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql);
-
- $stmtRemovePermissions = $this->getPDO()->prepare($sql);
- $stmtRemovePermissions->bindValue(':_uid', $id);
- if ($this->sharedTables) {
- $stmtRemovePermissions->bindValue(':_tenant', $document->getTenant());
- }
+ $attributes[Storage::CREATED_AT] = $document->getCreatedAt();
+ $attributes[Storage::UPDATED_AT] = $document->getUpdatedAt();
+ $attributes[Storage::PERMISSIONS] = json_encode($document->getPermissions());
- $values = [];
- $binds = [];
- foreach (Database::PERMISSIONS as $type) {
- foreach ($document->getPermissionsByType($type) as $i => $permission) {
- $tenantPlaceholder = $this->sharedTables ? ', :_tenant' : '';
- $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$tenantPlaceholder})";
- $binds[":_add_{$type}_{$i}"] = $permission;
- }
- }
-
- if (!empty($values)) {
- $tenantColumn = $this->sharedTables ? ', _tenant' : '';
-
- $sql = "
- INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$tenantColumn})
- VALUES " . \implode(', ', $values);
-
- $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql);
-
- $stmtAddPermissions = $this->getPDO()->prepare($sql);
- $stmtAddPermissions->bindValue(":_uid", $newUid);
- if ($this->sharedTables) {
- $stmtAddPermissions->bindValue(":_tenant", $document->getTenant());
- }
-
- foreach ($binds as $key => $permission) {
- $stmtAddPermissions->bindValue($key, $permission);
- }
- }
+ $version = $document->getVersion();
+ if ($version !== null) {
+ $attributes[Storage::VERSION] = $version;
}
- /**
- * Update Attributes
- */
- $keyIndex = 0;
- $operatorBinds = [];
+ $name = $this->filter($collection);
+ $operators = [];
foreach ($attributes as $attribute => $value) {
- $column = $this->filter($attribute);
-
- // Check if this is an operator or regular attribute
if (Operator::isOperator($value)) {
- $operatorSQL = $this->getOperatorSQL($column, $value, $operatorBinds);
- $columns .= $operatorSQL . ',';
- } else {
- $bindKey = 'key_' . $keyIndex;
-
- if (in_array($attribute, $spatialAttributes)) {
- $columns .= "`{$column}`" . '=' . $this->getSpatialGeomFromText(':' . $bindKey) . ',';
- } else {
- $columns .= "`{$column}`" . '=:' . $bindKey . ',';
- }
- $keyIndex++;
+ $operators[$attribute] = $value;
}
}
- $sql = "
- UPDATE {$this->getSQLTable($name)}
- SET " . \rtrim($columns, ',') . "
- WHERE _id=:_sequence
- {$this->getTenantQuery($collection)}
- ";
-
- $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql);
-
- $stmt = $this->getPDO()->prepare($sql);
-
- $stmt->bindValue(':_sequence', $document->getSequence());
-
- if ($this->sharedTables) {
- $stmt->bindValue(':_tenant', $this->tenant);
+ $builder = $this->newBuilder($name);
+ $regularRow = [];
+ if (\strcasecmp($document->getId(), $id) !== 0) {
+ $regularRow[Storage::UID] = $document->getId();
}
- $keyIndex = 0;
+ $spatialMap = \array_fill_keys($spatialAttributes, true);
+
foreach ($attributes as $attribute => $value) {
- // Handle operators separately
- if (Operator::isOperator($value)) {
- continue;
- }
+ $column = $this->filter($attribute);
- // Convert spatial arrays to WKT, json_encode non-spatial arrays
- if (\in_array($attribute, $spatialAttributes, true)) {
+ if (isset($operators[$attribute])) {
+ $op = $operators[$attribute];
+ if ($op instanceof Operator) {
+ $opResult = $this->getOperatorBuilderExpression($column, $op);
+ $builder->setRaw($column, $opResult['expression'], $opResult['bindings']);
+ }
+ } elseif (isset($spatialMap[$attribute]) || $this->isSpatialWkt($value)) {
+ $value = $this->encodeSpatialWriteValue($value);
+ $value = (\is_bool($value)) ? (int) $value : $value;
+ $builder->setRaw($column, $this->getSpatialGeomFromText('?'), [$value]);
+ } else {
if (\is_array($value)) {
- $value = $this->convertArrayToWKT($value);
+ $value = \json_encode($value);
}
- } elseif (is_array($value)) {
- $value = json_encode($value);
+ $value = (\is_bool($value)) ? (int) $value : $value;
+ $regularRow[$column] = $value;
}
-
- $bindKey = 'key_' . $keyIndex;
- $value = (is_bool($value)) ? (int)$value : $value;
- $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value));
- $keyIndex++;
- }
-
- foreach ($operatorBinds as $bindKey => $bindValue) {
- $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue));
}
- $stmt->execute();
+ $builder->set($regularRow);
+ $builder->filter([BaseQuery::equal(Storage::SEQUENCE, [$document->getSequence()])]);
+ $result = $builder->update();
+ $stmt = $this->executeResult($result, Event::DocumentUpdate);
- if (isset($stmtRemovePermissions)) {
- $stmtRemovePermissions->execute();
- }
- if (isset($stmtAddPermissions)) {
- $stmtAddPermissions->execute();
- }
+ $this->execute($stmt);
+ $ctx = $this->buildWriteContext($name, $id);
+ $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpdate($name, $document, $skipPermissions, $ctx));
} catch (PDOException $e) {
throw $this->processException($e);
}
@@ -1120,585 +778,423 @@ public function updateDocument(Document $collection, string $id, Document $docum
}
/**
- * @param string $tableName
- * @param string $columns
- * @param array $batchKeys
- * @param array $attributes
- * @param array $bindValues
- * @param string $attribute
- * @param array $operators
- * @return mixed
+ * Set max execution time
+ *
* @throws DatabaseException
*/
- public function getUpsertStatement(
- string $tableName,
- string $columns,
- array $batchKeys,
- array $attributes,
- array $bindValues,
- string $attribute = '',
- array $operators = []
- ): mixed {
- $getUpdateClause = function (string $attribute, bool $increment = false): string {
- $attribute = $this->quote($this->filter($attribute));
-
- if ($increment) {
- $new = "{$attribute} + VALUES({$attribute})";
- } else {
- $new = "VALUES({$attribute})";
- }
-
- if ($this->sharedTables) {
- return "{$attribute} = IF(_tenant = VALUES(_tenant), {$new}, {$attribute})";
- }
-
- return "{$attribute} = {$new}";
- };
-
- $updateColumns = [];
- $operatorBinds = [];
+ public function setTimeout(int $milliseconds, Event $event = Event::All): void
+ {
+ if ($milliseconds <= 0) {
+ throw new DatabaseException('Timeout must be greater than 0');
+ }
- if (!empty($attribute)) {
- // Increment specific column by its new value in place
- $updateColumns = [
- $getUpdateClause($attribute, increment: true),
- $getUpdateClause('_updatedAt'),
- ];
- } else {
- foreach (\array_keys($attributes) as $attr) {
- /**
- * @var string $attr
- */
- $filteredAttr = $this->filter($attr);
-
- if (isset($operators[$attr])) {
- $operatorSQL = $this->getOperatorSQL($filteredAttr, $operators[$attr], $operatorBinds);
- if ($operatorSQL !== null) {
- $updateColumns[] = $operatorSQL;
- }
- } else {
- if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) {
- $updateColumns[] = $getUpdateClause($filteredAttr);
- }
- }
- }
+ if ($event === Event::All) {
+ $this->applyTimeout($milliseconds);
}
- $stmt = $this->getPDO()->prepare(
- "
- INSERT INTO {$this->getSQLTable($tableName)} {$columns}
- VALUES " . \implode(', ', $batchKeys) . "
- ON DUPLICATE KEY UPDATE
- " . \implode(', ', $updateColumns)
- );
+ $this->setTimeoutState($milliseconds, $event);
+ }
- foreach ($bindValues as $key => $binding) {
- $stmt->bindValue($key, $binding, $this->getPDOType($binding));
+ public function clearTimeout(Event $event = Event::All): void
+ {
+ if ($event === Event::All) {
+ $this->applyTimeout(0);
}
- foreach ($operatorBinds as $bindKey => $bindValue) {
- $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue));
- }
+ $this->clearTimeoutState($event);
+ }
- return $stmt;
+ /**
+ * Size of POINT spatial type
+ */
+ protected function getMaxPointSize(): int
+ {
+ // https://dev.mysql.com/doc/refman/8.4/en/gis-data-formats.html#gis-internal-format
+ return 25;
}
/**
- * Increase or decrease an attribute value
+ * Decode a WKB or WKT POINT into a coordinate array [x, y].
*
- * @param string $collection
- * @param string $id
- * @param string $attribute
- * @param int|float $value
- * @param string $updatedAt
- * @param int|float|null $min
- * @param int|float|null $max
- * @return bool
- * @throws DatabaseException
+ * @param string $wkb The WKB binary or WKT string
+ * @return array
+ *
+ * @throws DatabaseException If the input is invalid.
*/
- public function increaseDocumentAttribute(
- string $collection,
- string $id,
- string $attribute,
- int|float $value,
- string $updatedAt,
- int|float|null $min = null,
- int|float|null $max = null
- ): bool {
- $name = $this->filter($collection);
- $attribute = $this->filter($attribute);
-
- $sqlMax = $max !== null ? " AND `{$attribute}` <= :max" : '';
- $sqlMin = $min !== null ? " AND `{$attribute}` >= :min" : '';
-
- $sql = "
- UPDATE {$this->getSQLTable($name)}
- SET
- `{$attribute}` = `{$attribute}` + :val,
- `_updatedAt` = :updatedAt
- WHERE _uid = :_uid
- {$this->getTenantQuery($collection)}
- ";
+ #[\Override]
+ public function decodePoint(string $wkb): array
+ {
+ if (str_starts_with(strtoupper($wkb), 'POINT(')) {
+ $start = strpos($wkb, '(') + 1;
+ $end = strrpos($wkb, ')');
+ $inside = substr($wkb, $start, $end - $start);
+ $coords = explode(' ', trim($inside));
- $sql .= $sqlMax . $sqlMin;
+ return [(float) $coords[0], (float) $coords[1]];
+ }
- $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql);
+ /**
+ * [0..3] SRID (4 bytes, little-endian)
+ * [4] Byte order (1 = little-endian, 0 = big-endian)
+ * [5..8] Geometry type (with SRID flag bit)
+ * [9..] Geometry payload (coordinates, etc.)
+ */
+ if (strlen($wkb) < 25) {
+ throw new DatabaseException('Invalid WKB: too short for POINT');
+ }
- $stmt = $this->getPDO()->prepare($sql);
- $stmt->bindValue(':_uid', $id);
- $stmt->bindValue(':val', $value);
- $stmt->bindValue(':updatedAt', $updatedAt);
+ // 4 bytes SRID first → skip to byteOrder at offset 4
+ $byteOrder = ord($wkb[4]);
+ $littleEndian = ($byteOrder === 1);
- if ($max !== null) {
- $stmt->bindValue(':max', $max);
- }
- if ($min !== null) {
- $stmt->bindValue(':min', $min);
+ if (! $littleEndian) {
+ throw new DatabaseException('Only little-endian WKB supported');
}
- if ($this->sharedTables) {
- $stmt->bindValue(':_tenant', $this->tenant);
+
+ // After SRID (4) + byteOrder (1) + type (4) = 9 bytes
+ $coordsBin = substr($wkb, 9, 16);
+ if (strlen($coordsBin) !== 16) {
+ throw new DatabaseException('Invalid WKB: missing coordinate bytes');
}
- try {
- $stmt->execute();
- } catch (PDOException $e) {
- throw $this->processException($e);
+ // Unpack two doubles
+ $coords = unpack('d2', $coordsBin);
+ if ($coords === false || ! isset($coords[1], $coords[2])) {
+ throw new DatabaseException('Invalid WKB: failed to unpack coordinates');
}
- return true;
+ return [(float) (is_numeric($coords[1]) ? $coords[1] : 0), (float) (is_numeric($coords[2]) ? $coords[2] : 0)];
}
/**
- * Delete Document
+ * Decode a WKB or WKT LINESTRING into an array of coordinate pairs.
*
- * @param string $collection
- * @param string $id
- * @return bool
- * @throws Exception
- * @throws PDOException
+ * @param string $wkb The WKB binary or WKT string
+ * @return array>
+ *
+ * @throws DatabaseException If the input is invalid.
*/
- public function deleteDocument(string $collection, string $id): bool
+ #[\Override]
+ public function decodeLinestring(string $wkb): array
{
- try {
- $name = $this->filter($collection);
-
- $sql = "
- DELETE FROM {$this->getSQLTable($name)}
- WHERE _uid = :_uid
- {$this->getTenantQuery($collection)}
- ";
+ if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) {
+ $start = strpos($wkb, '(') + 1;
+ $end = strrpos($wkb, ')');
+ $inside = substr($wkb, $start, $end - $start);
- $sql = $this->trigger(Database::EVENT_DOCUMENT_DELETE, $sql);
+ $points = explode(',', $inside);
- $stmt = $this->getPDO()->prepare($sql);
+ return array_map(function ($point) {
+ $coords = explode(' ', trim($point));
- $stmt->bindValue(':_uid', $id);
-
- if ($this->sharedTables) {
- $stmt->bindValue(':_tenant', $this->tenant);
- }
+ return [(float) $coords[0], (float) $coords[1]];
+ }, $points);
+ }
- $sql = "
- DELETE FROM {$this->getSQLTable($name . '_perms')}
- WHERE _document = :_uid
- {$this->getTenantQuery($collection)}
- ";
+ // Skip 1 byte (endianness) + 4 bytes (type) + 4 bytes (SRID)
+ $offset = 9;
- $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql);
+ // Number of points (4 bytes little-endian)
+ $numPointsArr = unpack('V', substr($wkb, $offset, 4));
+ if ($numPointsArr === false || ! isset($numPointsArr[1])) {
+ throw new DatabaseException('Invalid WKB: cannot unpack number of points');
+ }
- $stmtPermissions = $this->getPDO()->prepare($sql);
- $stmtPermissions->bindValue(':_uid', $id);
+ $numPoints = $numPointsArr[1];
+ $offset += 4;
- if ($this->sharedTables) {
- $stmtPermissions->bindValue(':_tenant', $this->tenant);
- }
+ $points = [];
+ for ($i = 0; $i < $numPoints; $i++) {
+ $xArr = unpack('d', substr($wkb, $offset, 8));
+ $yArr = unpack('d', substr($wkb, $offset + 8, 8));
- if (!$stmt->execute()) {
- throw new DatabaseException('Failed to delete document');
+ if ($xArr === false || ! isset($xArr[1]) || $yArr === false || ! isset($yArr[1])) {
+ throw new DatabaseException('Invalid WKB: cannot unpack point coordinates');
}
- $deleted = $stmt->rowCount();
-
- if (!$stmtPermissions->execute()) {
- throw new DatabaseException('Failed to delete permissions');
- }
- } catch (\Throwable $e) {
- throw new DatabaseException($e->getMessage(), $e->getCode(), $e);
+ $points[] = [(float) (is_numeric($xArr[1]) ? $xArr[1] : 0), (float) (is_numeric($yArr[1]) ? $yArr[1] : 0)];
+ $offset += 16;
}
- return $deleted;
+ return $points;
}
/**
- * Handle distance spatial queries
+ * Decode a WKB or WKT POLYGON into an array of rings, each containing coordinate pairs.
*
- * @param Query $query
- * @param array $binds
- * @param string $attribute
- * @param string $type
- * @param string $alias
- * @param string $placeholder
- * @return string
- */
- protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string
- {
- $distanceParams = $query->getValues()[0];
- $wkt = $this->convertArrayToWKT($distanceParams[0]);
- $binds[":{$placeholder}_0"] = $wkt;
- $binds[":{$placeholder}_1"] = $distanceParams[1];
-
- $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true;
-
- switch ($query->getMethod()) {
- case Query::TYPE_DISTANCE_EQUAL:
- $operator = '=';
- break;
- case Query::TYPE_DISTANCE_NOT_EQUAL:
- $operator = '!=';
- break;
- case Query::TYPE_DISTANCE_GREATER_THAN:
- $operator = '>';
- break;
- case Query::TYPE_DISTANCE_LESS_THAN:
- $operator = '<';
- break;
- default:
- throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod());
- }
-
- if ($useMeters) {
- $wktType = $this->getSpatialTypeFromWKT($wkt);
- $attrType = strtolower($type);
- if ($wktType != Database::VAR_POINT || $attrType != Database::VAR_POINT) {
- throw new QueryException('Distance in meters is not supported between '.$attrType . ' and '. $wktType);
- }
- return "ST_DISTANCE_SPHERE({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::EARTH_RADIUS . ") {$operator} :{$placeholder}_1";
- }
- return "ST_Distance({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ") {$operator} :{$placeholder}_1";
- }
-
- /**
- * Handle spatial queries
+ * @param string $wkb The WKB binary or WKT string
+ * @return array>>
*
- * @param Query $query
- * @param array $binds
- * @param string $attribute
- * @param string $type
- * @param string $alias
- * @param string $placeholder
- * @return string
+ * @throws DatabaseException If the input is invalid.
*/
- protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string
+ #[\Override]
+ public function decodePolygon(string $wkb): array
{
- switch ($query->getMethod()) {
- case Query::TYPE_CROSSES:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ // POLYGON((x1,y1),(x2,y2))
+ if (str_starts_with($wkb, 'POLYGON((')) {
+ $start = strpos($wkb, '((') + 2;
+ $end = strrpos($wkb, '))');
+ $inside = substr($wkb, $start, $end - $start);
- case Query::TYPE_NOT_CROSSES:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "NOT ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ $rings = explode('),(', $inside);
- case Query::TYPE_DISTANCE_EQUAL:
- case Query::TYPE_DISTANCE_NOT_EQUAL:
- case Query::TYPE_DISTANCE_GREATER_THAN:
- case Query::TYPE_DISTANCE_LESS_THAN:
- return $this->handleDistanceSpatialQueries($query, $binds, $attribute, $type, $alias, $placeholder);
+ return array_map(function ($ring) {
+ $points = explode(',', $ring);
- case Query::TYPE_INTERSECTS:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ return array_map(function ($point) {
+ $coords = explode(' ', trim($point));
- case Query::TYPE_NOT_INTERSECTS:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "NOT ST_Intersects({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ return [(float) $coords[0], (float) $coords[1]];
+ }, $points);
+ }, $rings);
+ }
- case Query::TYPE_OVERLAPS:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ // Convert HEX string to binary if needed
+ if (str_starts_with($wkb, '0x') || ctype_xdigit($wkb)) {
+ $wkb = hex2bin(str_starts_with($wkb, '0x') ? substr($wkb, 2) : $wkb);
+ if ($wkb === false) {
+ throw new DatabaseException('Invalid hex WKB');
+ }
+ }
- case Query::TYPE_NOT_OVERLAPS:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "NOT ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ if (strlen($wkb) < 21) {
+ throw new DatabaseException('WKB too short to be a POLYGON');
+ }
- case Query::TYPE_TOUCHES:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ // MySQL SRID-aware WKB layout: 4 bytes SRID prefix
+ $offset = 4;
- case Query::TYPE_NOT_TOUCHES:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "NOT ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ $byteOrder = ord($wkb[$offset]);
+ if ($byteOrder !== 1) {
+ throw new DatabaseException('Only little-endian WKB supported');
+ }
+ $offset += 1;
- case Query::TYPE_EQUAL:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ $typeArr = unpack('V', substr($wkb, $offset, 4));
+ if ($typeArr === false || ! isset($typeArr[1])) {
+ throw new DatabaseException('Invalid WKB: cannot unpack geometry type');
+ }
- case Query::TYPE_NOT_EQUAL:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "NOT ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ $type = \is_numeric($typeArr[1]) ? (int) $typeArr[1] : 0;
+ $hasSRID = ($type & 0x20000000) === 0x20000000;
+ $geomType = $type & 0xFF;
+ $offset += 4;
- case Query::TYPE_CONTAINS:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ if ($geomType !== 3) { // 3 = POLYGON
+ throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}");
+ }
- case Query::TYPE_NOT_CONTAINS:
- $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]);
- return "NOT ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")";
+ // Skip SRID in type flag if present
+ if ($hasSRID) {
+ $offset += 4;
+ }
- case Query::TYPE_IS_NULL:
- case Query::TYPE_IS_NOT_NULL:
- return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}";
+ $numRingsArr = unpack('V', substr($wkb, $offset, 4));
- default:
- throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod());
+ if ($numRingsArr === false || ! isset($numRingsArr[1])) {
+ throw new DatabaseException('Invalid WKB: cannot unpack number of rings');
}
- }
-
- /**
- * Get SQL Condition
- *
- * @param Query $query
- * @param array $binds
- * @return string
- * @throws Exception
- */
- protected function getSQLCondition(Query $query, array &$binds, ?string $forCollection = null): string
- {
- $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute()));
- $attribute = $query->getAttribute();
- $attribute = $this->filter($attribute);
- $attribute = $this->quote($attribute);
- $alias = $this->quote(Query::DEFAULT_ALIAS);
- $placeholder = ID::unique();
+ $numRings = $numRingsArr[1];
+ $offset += 4;
- if ($query->isSpatialAttribute()) {
- return $this->handleSpatialQueries($query, $binds, $attribute, $query->getAttributeType(), $alias, $placeholder);
- }
+ $rings = [];
- switch ($query->getMethod()) {
- case Query::TYPE_OR:
- case Query::TYPE_AND:
- $conditions = [];
- /* @var $q Query */
- foreach ($query->getValue() as $q) {
- $conditions[] = $this->getSQLCondition($q, $binds, $forCollection);
- }
+ for ($r = 0; $r < $numRings; $r++) {
+ $numPointsArr = unpack('V', substr($wkb, $offset, 4));
- $method = strtoupper($query->getMethod());
+ if ($numPointsArr === false || ! isset($numPointsArr[1])) {
+ throw new DatabaseException('Invalid WKB: cannot unpack number of points');
+ }
- return empty($conditions) ? '' : ' '. $method .' (' . implode(' AND ', $conditions) . ')';
+ $numPoints = $numPointsArr[1];
+ $offset += 4;
+ $ring = [];
- case Query::TYPE_SEARCH:
- $fulltextValue = $this->getFulltextValue($query->getValue());
- if ($fulltextValue === '') {
- return '0 = 1';
+ for ($p = 0; $p < $numPoints; $p++) {
+ $xArr = unpack('d', substr($wkb, $offset, 8));
+ if ($xArr === false) {
+ throw new DatabaseException('Failed to unpack X coordinate from WKB.');
}
- $binds[":{$placeholder}_0"] = $fulltextValue;
- return "MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)";
+ $x = (float) (is_numeric($xArr[1]) ? $xArr[1] : 0);
- case Query::TYPE_NOT_SEARCH:
- $fulltextValue = $this->getFulltextValue($query->getValue());
- if ($fulltextValue === '') {
- return '1 = 1';
+ $yArr = unpack('d', substr($wkb, $offset + 8, 8));
+ if ($yArr === false) {
+ throw new DatabaseException('Failed to unpack Y coordinate from WKB.');
}
- $binds[":{$placeholder}_0"] = $fulltextValue;
-
- return "NOT (MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE))";
- case Query::TYPE_BETWEEN:
- $binds[":{$placeholder}_0"] = $query->getValues()[0];
- $binds[":{$placeholder}_1"] = $query->getValues()[1];
+ $y = (float) (is_numeric($yArr[1]) ? $yArr[1] : 0);
- return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1";
+ $ring[] = [$x, $y];
+ $offset += 16;
+ }
- case Query::TYPE_NOT_BETWEEN:
- $binds[":{$placeholder}_0"] = $query->getValues()[0];
- $binds[":{$placeholder}_1"] = $query->getValues()[1];
+ $rings[] = $ring;
+ }
- return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1";
+ return $rings;
+ }
- case Query::TYPE_IS_NULL:
- case Query::TYPE_IS_NOT_NULL:
+ /** Last value pushed to MariaDB session var max_statement_time, in seconds. */
+ private float $appliedMaxStatementTime = 0.0;
- return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}";
- case Query::TYPE_CONTAINS_ALL:
- if ($query->onArray()) {
- $binds[":{$placeholder}_0"] = json_encode($query->getValues());
- return "JSON_CONTAINS({$alias}.{$attribute}, :{$placeholder}_0)";
- }
- // no break
- case Query::TYPE_CONTAINS:
- case Query::TYPE_CONTAINS_ANY:
- case Query::TYPE_NOT_CONTAINS:
- if ($query->onArray()) {
- $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS;
-
- if ($this->getSupportForJSONOverlaps()) {
- $binds[":{$placeholder}_0"] = json_encode($query->getValues());
- return $isNot
- ? "NOT (JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0))"
- : "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)";
- }
+ /**
+ * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt
+ */
+ protected function execute(mixed $stmt, ?Event $event = null): bool
+ {
+ $event ??= $this->getStatementEvent($stmt);
+ $baseline = $this->getTimeout();
+ $timeout = $event === null ? $baseline : $this->getTimeout($event);
+ $this->applyTimeout($timeout);
- // JSON_CONTAINS per element OR'd together — exact
- // element match without LIKE's substring false positives
- // (`%2%` matching `[12, 200]`, `%"apple"%` matching
- // `["pineapple"]`).
- $conditions = [];
- foreach ($query->getValues() as $key => $value) {
- $binds[":{$placeholder}_{$key}"] = json_encode($value);
- $conditions[] = "JSON_CONTAINS({$alias}.{$attribute}, :{$placeholder}_{$key})";
- }
- if (empty($conditions)) {
- return '';
- }
- $expression = '(' . implode(' OR ', $conditions) . ')';
- return $isNot ? "NOT {$expression}" : $expression;
- }
- // no break
- default:
- $conditions = [];
- $isNotQuery = in_array($query->getMethod(), [
- Query::TYPE_NOT_STARTS_WITH,
- Query::TYPE_NOT_ENDS_WITH,
- Query::TYPE_NOT_CONTAINS
- ]);
-
- foreach ($query->getValues() as $key => $value) {
- $value = match ($query->getMethod()) {
- Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%',
- Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%',
- Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value),
- Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value),
- Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS => '%' . $this->escapeWildcards($value) . '%',
- default => $value
- };
-
- $binds[":{$placeholder}_{$key}"] = $value;
- if ($isNotQuery) {
- $conditions[] = "{$alias}.{$attribute} NOT {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}";
- } else {
- $conditions[] = "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}";
+ $exception = null;
+ try {
+ return parent::execute($stmt, $event);
+ } catch (Throwable $error) {
+ $exception = $error;
+ throw $error;
+ } finally {
+ if ($timeout !== $baseline) {
+ try {
+ $this->applyTimeout($baseline);
+ } catch (Throwable $error) {
+ if ($exception === null) {
+ throw $error;
}
}
-
- $separator = $isNotQuery ? ' AND ' : ' OR ';
- return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')';
+ }
}
}
- /**
- * Get SQL Type
- *
- * @param string $type
- * @param int $size
- * @param bool $signed
- * @param bool $array
- * @param bool $required
- * @return string
- * @throws DatabaseException
- */
- protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string
+ private function applyTimeout(int $milliseconds): void
{
- if (in_array($type, Database::SPATIAL_TYPES)) {
- return $this->getSpatialSQLType($type, $required);
- }
- if ($array === true) {
- return 'JSON';
+ $seconds = $milliseconds > 0 ? $milliseconds / 1000.0 : 0.0;
+ if ($seconds === $this->appliedMaxStatementTime) {
+ return;
}
- switch ($type) {
- case Database::VAR_ID:
- return 'BIGINT UNSIGNED';
-
- case Database::VAR_STRING:
- // $size = $size * 4; // Convert utf8mb4 size to bytes
- if ($size > Database::MAX_MEDIUMTEXT_BYTES) {
- return 'LONGTEXT';
- }
-
- if ($size > Database::MAX_TEXT_BYTES) {
- return 'MEDIUMTEXT';
- }
-
- if ($size > $this->getMaxVarcharLength()) {
- return 'TEXT';
- }
-
- return "VARCHAR({$size})";
-
- case Database::VAR_VARCHAR:
- if ($size <= 0) {
- throw new DatabaseException('VARCHAR size ' . $size . ' is invalid; must be > 0. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.');
- }
- if ($size > $this->getMaxVarcharLength()) {
- throw new DatabaseException('VARCHAR size ' . $size . ' exceeds maximum varchar length ' . $this->getMaxVarcharLength() . '. Use TEXT, MEDIUMTEXT, or LONGTEXT instead.');
- }
- return "VARCHAR({$size})";
-
- case Database::VAR_TEXT:
- return 'TEXT';
+ $this->getPDO()->exec('SET max_statement_time = '.\sprintf('%.6F', $seconds));
+ $this->appliedMaxStatementTime = $seconds;
+ }
- case Database::VAR_MEDIUMTEXT:
- return 'MEDIUMTEXT';
+ /**
+ * {@inheritDoc}
+ */
+ protected function getConflictTenantExpression(string $column): string
+ {
+ $quoted = $this->quote($this->filter($column));
+ $tenant = Storage::TENANT;
- case Database::VAR_LONGTEXT:
- return 'LONGTEXT';
+ return "IF({$tenant} = VALUES({$tenant}), VALUES({$quoted}), {$quoted})";
+ }
- case Database::VAR_INTEGER: // We don't support zerofill: https://stackoverflow.com/a/5634147/2299554
- $signed = ($signed) ? '' : ' UNSIGNED';
+ /**
+ * {@inheritDoc}
+ */
+ protected function getConflictIncrementExpression(string $column): string
+ {
+ $quoted = $this->quote($this->filter($column));
- if ($size >= 8) { // INT = 4 bytes, BIGINT = 8 bytes
- return 'BIGINT' . $signed;
- }
+ return "{$quoted} + VALUES({$quoted})";
+ }
- return 'INT' . $signed;
+ /**
+ * {@inheritDoc}
+ */
+ protected function getConflictTenantIncrementExpression(string $column): string
+ {
+ $quoted = $this->quote($this->filter($column));
+ $tenant = Storage::TENANT;
- case Database::VAR_BIGINT:
- $signed = ($signed) ? '' : ' UNSIGNED';
- return 'BIGINT' . $signed;
+ return "IF({$tenant} = VALUES({$tenant}), {$quoted} + VALUES({$quoted}), {$quoted})";
+ }
- case Database::VAR_FLOAT:
- $signed = ($signed) ? '' : ' UNSIGNED';
- return 'DOUBLE' . $signed;
+ /**
+ * Handle distance spatial queries
+ *
+ * @param array $binds
+ */
+ protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string
+ {
+ /** @var array $distanceParams */
+ $distanceParams = $query->getValues()[0];
+ /** @var array $geomArray */
+ $geomArray = \is_array($distanceParams[0]) ? $distanceParams[0] : [];
+ $wkt = $this->convertArrayToWKT($geomArray);
+ $binds[":{$placeholder}_0"] = $wkt;
+ $binds[":{$placeholder}_1"] = $distanceParams[1];
- case Database::VAR_BOOLEAN:
- return 'TINYINT(1)';
+ $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true;
- case Database::VAR_RELATIONSHIP:
- return 'VARCHAR(255)';
+ $operator = match ($query->getMethod()) {
+ Method::DistanceEqual => '=',
+ Method::DistanceNotEqual => '!=',
+ Method::DistanceGreaterThan => '>',
+ Method::DistanceLessThan => '<',
+ default => throw new DatabaseException('Unknown spatial query method: '.$query->getMethod()->value),
+ };
- case Database::VAR_DATETIME:
- return 'DATETIME(3)';
+ if ($useMeters) {
+ $wktType = $this->getSpatialTypeFromWKT($wkt);
+ $attrType = strtolower($type);
+ if ($wktType != ColumnType::Point->value || $attrType != ColumnType::Point->value) {
+ throw new QueryException('Distance in meters is not supported between '.$attrType.' and '.$wktType);
+ }
- default:
- throw new DatabaseException('Unknown type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_VARCHAR . ', ' . Database::VAR_TEXT . ', ' . Database::VAR_MEDIUMTEXT . ', ' . Database::VAR_LONGTEXT . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_BIGINT . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON);
+ return "ST_DISTANCE_SPHERE({$alias}.{$attribute}, ".$this->getSpatialGeomFromText(":{$placeholder}_0", null).', '.Database::EARTH_RADIUS.") {$operator} :{$placeholder}_1";
}
+
+ return "ST_Distance({$alias}.{$attribute}, ".$this->getSpatialGeomFromText(":{$placeholder}_0", null).") {$operator} :{$placeholder}_1";
}
/**
- * Get PDO Type
+ * Handle spatial queries
*
- * @param mixed $value
- * @return int
- * @throws Exception
+ * @param array $binds
*/
- protected function getPDOType(mixed $value): int
+ protected function handleSpatialQueries(Query $query, array &$binds, string $attribute, string $type, string $alias, string $placeholder): string
{
- return match (gettype($value)) {
- 'string','double' => \PDO::PARAM_STR,
- 'integer', 'boolean' => \PDO::PARAM_INT,
- 'NULL' => \PDO::PARAM_NULL,
- default => throw new DatabaseException('Unknown PDO Type for ' . \gettype($value)),
+ /** @var array $spatialGeomArr */
+ $spatialGeomArr = \is_array($query->getValues()[0]) ? $query->getValues()[0] : [];
+ $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($spatialGeomArr);
+ $geom = $this->getSpatialGeomFromText(":{$placeholder}_0", null);
+
+ return match ($query->getMethod()) {
+ Method::Crosses => "ST_Crosses({$alias}.{$attribute}, {$geom})",
+ Method::NotCrosses => "NOT ST_Crosses({$alias}.{$attribute}, {$geom})",
+ Method::DistanceEqual,
+ Method::DistanceNotEqual,
+ Method::DistanceGreaterThan,
+ Method::DistanceLessThan => $this->handleDistanceSpatialQueries($query, $binds, $attribute, $type, $alias, $placeholder),
+ Method::Intersects => "ST_Intersects({$alias}.{$attribute}, {$geom})",
+ Method::NotIntersects => "NOT ST_Intersects({$alias}.{$attribute}, {$geom})",
+ Method::Overlaps => "ST_Overlaps({$alias}.{$attribute}, {$geom})",
+ Method::NotOverlaps => "NOT ST_Overlaps({$alias}.{$attribute}, {$geom})",
+ Method::Touches => "ST_Touches({$alias}.{$attribute}, {$geom})",
+ Method::NotTouches => "NOT ST_Touches({$alias}.{$attribute}, {$geom})",
+ Method::Equal => "ST_Equals({$alias}.{$attribute}, {$geom})",
+ Method::NotEqual => "NOT ST_Equals({$alias}.{$attribute}, {$geom})",
+ Method::Contains => "ST_Contains({$alias}.{$attribute}, {$geom})",
+ Method::NotContains => "NOT ST_Contains({$alias}.{$attribute}, {$geom})",
+ default => throw new DatabaseException('Unknown spatial query method: '.$query->getMethod()->value),
};
}
+ protected function createBuilder(): SQLBuilder
+ {
+ return new MariaDBBuilder();
+ }
+
+ #[\Override]
+ protected function createSchemaBuilder(): MySQLSchema
+ {
+ return new MySQLSchema();
+ }
+
/**
- * Get the SQL function for random ordering
- *
- * @return string
+ * Get the SQL function for random ordering.
*/
protected function getRandomOrder(): string
{
@@ -1706,275 +1202,62 @@ protected function getRandomOrder(): string
}
/**
- * Size of POINT spatial type
+ * Get Schema Attributes
*
- * @return int
- */
- protected function getMaxPointSize(): int
- {
- // https://dev.mysql.com/doc/refman/8.4/en/gis-data-formats.html#gis-internal-format
- return 25;
- }
-
- public function getMinDateTime(): \DateTime
- {
- return new \DateTime('1000-01-01 00:00:00');
- }
-
- public function getMaxDateTime(): \DateTime
- {
- return new \DateTime('9999-12-31 23:59:59');
- }
-
- /**
- * Is fulltext Wildcard index supported?
- *
- * @return bool
- */
- public function getSupportForFulltextWildcardIndex(): bool
- {
- return true;
- }
-
- /**
- * Does the adapter handle Query Array Overlaps?
- *
- * @return bool
- */
- public function getSupportForJSONOverlaps(): bool
- {
- return true;
- }
-
- public function getSupportForIntegerBooleans(): bool
- {
- return true;
- }
-
- /**
- * Are timeouts supported?
+ * @return array
*
- * @return bool
+ * @throws DatabaseException
*/
- public function getSupportForTimeouts(): bool
- {
- return true;
- }
-
- public function getSupportForUpserts(): bool
- {
- return true;
- }
-
- public function getSupportForUpsertOnUniqueIndex(): bool
- {
- return true;
- }
-
- public function getSupportForSchemaAttributes(): bool
- {
- return true;
- }
-
- public function getSupportForSchemaIndexes(): bool
- {
- return true;
- }
-
- public function getSchemaIndexes(string $collection): array
+ public function getSchemaAttributes(string $collection): array
{
$schema = $this->getDatabase();
- $collection = $this->getNamespace() . '_' . $this->filter($collection);
+ $collection = $this->getNamespace().'_'.$this->filter($collection);
try {
- $stmt = $this->getPDO()->prepare('
+ $stmt = $this->prepareStatement('
SELECT
- INDEX_NAME as indexName,
- COLUMN_NAME as columnName,
- NON_UNIQUE as nonUnique,
- SEQ_IN_INDEX as seqInIndex,
- INDEX_TYPE as indexType,
- SUB_PART as subPart
- FROM INFORMATION_SCHEMA.STATISTICS
+ COLUMN_NAME as '.Storage::SEQUENCE.',
+ COLUMN_DEFAULT as columnDefault,
+ IS_NULLABLE as isNullable,
+ DATA_TYPE as dataType,
+ CHARACTER_MAXIMUM_LENGTH as characterMaximumLength,
+ NUMERIC_PRECISION as numericPrecision,
+ NUMERIC_SCALE as numericScale,
+ DATETIME_PRECISION as datetimePrecision,
+ COLUMN_TYPE as columnType,
+ COLUMN_KEY as columnKey,
+ EXTRA as extra
+ FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
- ORDER BY INDEX_NAME, SEQ_IN_INDEX
- ');
+ ', Event::CollectionRead);
$stmt->bindParam(':schema', $schema);
$stmt->bindParam(':table', $collection);
- $stmt->execute();
- $rows = $stmt->fetchAll();
+ $this->execute($stmt);
+ $results = $stmt->fetchAll();
$stmt->closeCursor();
- $grouped = [];
- foreach ($rows as $row) {
- $name = $row['indexName'];
- if (!isset($grouped[$name])) {
- $grouped[$name] = [
- '$id' => $name,
- 'indexName' => $name,
- 'indexType' => $row['indexType'],
- 'nonUnique' => (int)$row['nonUnique'],
- 'columns' => [],
- 'lengths' => [],
- ];
- }
- $grouped[$name]['columns'][] = $row['columnName'];
- $grouped[$name]['lengths'][] = $row['subPart'] !== null ? (int)$row['subPart'] : null;
- }
-
- return \array_map(fn ($idx) => new Document($idx), \array_values($grouped));
- } catch (PDOException $e) {
- throw new DatabaseException('Failed to get schema indexes', $e->getCode(), $e);
- }
- }
-
- /**
- * Set max execution time
- * @param int $milliseconds
- * @param string $event
- * @return void
- * @throws DatabaseException
- */
- public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void
- {
- if (!$this->getSupportForTimeouts()) {
- return;
- }
- if ($milliseconds <= 0) {
- throw new DatabaseException('Timeout must be greater than 0');
- }
-
- $this->timeout = $milliseconds;
-
- $seconds = $milliseconds / 1000;
-
- $this->before($event, 'timeout', function ($sql) use ($seconds) {
- return "SET STATEMENT max_statement_time = {$seconds} FOR " . $sql;
- });
- }
-
- /**
- * @return string
- */
- public function getConnectionId(): string
- {
- $stmt = $this->getPDO()->query("SELECT CONNECTION_ID();");
- return $stmt->fetchColumn();
- }
-
- public function getInternalIndexesKeys(): array
- {
- return ['primary', '_created_at', '_updated_at', '_tenant_id'];
- }
-
- protected function processException(PDOException $e): \Exception
- {
- if ($e->getCode() === '22007' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1366) {
- return new CharacterException('Invalid character', $e->getCode(), $e);
- }
+ $docs = [];
+ foreach ($results as $document) {
+ /** @var array $document */
+ $document[Document::ID] = $document[Storage::SEQUENCE];
+ unset($document[Storage::SEQUENCE]);
- // Timeout
- if ($e->getCode() === '70100' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1969) {
- return new TimeoutException('Query timed out', $e->getCode(), $e);
- }
-
- // Duplicate table
- if ($e->getCode() === '42S01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1050) {
- return new DuplicateException('Collection already exists', $e->getCode(), $e);
- }
-
- // Duplicate column
- if ($e->getCode() === '42S21' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1060) {
- return new DuplicateException('Attribute already exists', $e->getCode(), $e);
- }
-
- // Duplicate index
- if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1061) {
- return new DuplicateException('Index already exists', $e->getCode(), $e);
- }
-
- // Duplicate row
- if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) {
- $key = $this->getViolatedKey($e->getMessage());
- if ($key === '_index1') {
- return new DuplicateException('Duplicate permissions for document', $e->getCode(), $e);
- }
- if ($key !== null && $key !== '_uid' && $key !== 'PRIMARY') {
- return new UniqueException('Unique index violation', $e->getCode(), $e);
+ $docs[] = new Document($document);
}
- return new DuplicateException('Document already exists', $e->getCode(), $e);
- }
-
- // Data is too big for column resize
- if (($e->getCode() === '22001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1406) ||
- ($e->getCode() === '01000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1265)) {
- return new TruncateException('Resize would result in data truncation', $e->getCode(), $e);
- }
-
- // Numeric value out of range
- if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1264 || $e->errorInfo[1] === 1690)) {
- return new LimitException('Value out of range', $e->getCode(), $e);
- }
-
- // Numeric value out of range
- if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1690) {
- return new LimitException('Value is out of range', $e->getCode(), $e);
- }
-
- // Unknown database
- if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) {
- return new NotFoundException('Database not found', $e->getCode(), $e);
- }
+ $results = $docs;
- // Unknown collection
- if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) {
- return new NotFoundException('Collection not found', $e->getCode(), $e);
- }
-
- // Unknown collection
- // We have two of same, because docs point to 1051.
- // Keeping previous 1049 (above) just in case it's for older versions
- if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1051) {
- return new NotFoundException('Collection not found', $e->getCode(), $e);
- }
-
- // Unknown column
- if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) {
- return new NotFoundException('Attribute not found', $e->getCode(), $e);
- }
-
- return $e;
- }
+ return $results;
- /**
- * Extract the index name from a duplicate entry error, e.g.
- * "Duplicate entry 'x' for key 'movies._uid'" resolves to "_uid".
- * Returns null when the message cannot be parsed.
- */
- protected function getViolatedKey(string $message): ?string
- {
- if (\preg_match("/for key '(?:[^'.]*\.)?([^']+)'/", $message, $matches) === 1) {
- return $matches[1];
+ } catch (PDOException $e) {
+ throw new DatabaseException('Failed to get schema attributes', $e->getCode(), $e);
}
-
- return null;
- }
-
- protected function quote(string $string): string
- {
- return "`{$string}`";
}
/**
* Get operator SQL
* Override to handle MariaDB/MySQL-specific operators
- *
- * @param string $column
- * @param Operator $operator
- * @param array $binds
- * @return ?string
*/
- protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string
+ protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex): ?string
{
$quotedColumn = $this->quote($column);
$method = $operator->getMethod();
@@ -1982,161 +1265,171 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
switch ($method) {
// Numeric operators
- case Operator::TYPE_INCREMENT:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
+ case OperatorType::Increment:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
if (isset($values[1])) {
- $maxKey = $this->registerOperatorBind($binds, $values[1]);
- // Compare with the operand moved across (`col > max - val`) instead of
- // `col + val > max`, so the guard never overflows BIGINT when col is near the
- // integer range limit. Inclusive: a result landing exactly on max still applies.
+ $maxKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = CASE
WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) + :$bindKey
END";
}
+
return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) + :$bindKey";
- case Operator::TYPE_DECREMENT:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
+ case OperatorType::Decrement:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
if (isset($values[1])) {
- $minKey = $this->registerOperatorBind($binds, $values[1]);
- // `col < min + val` rather than `col - val < min`: overflow-safe near the
- // integer range limit. Inclusive: a result landing exactly on min still applies.
+ $minKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = CASE
WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) - :$bindKey
END";
}
+
return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) - :$bindKey";
- case Operator::TYPE_MULTIPLY:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
+ case OperatorType::Multiply:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
if (isset($values[1])) {
- $maxKey = $this->registerOperatorBind($binds, $values[1]);
- // Compare via division (`col > max/val`, sign-aware) instead of computing
- // `col * val`, which would overflow BIGINT for large operands. The factor's
- // sign flips the inequality. Inclusive: a result exactly on max still applies.
+ $maxKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = CASE
WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0)
WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) * :$bindKey
END";
}
+
return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) * :$bindKey";
- case Operator::TYPE_DIVIDE:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
+ case OperatorType::Divide:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
if (isset($values[1])) {
- $minKey = $this->registerOperatorBind($binds, $values[1]);
+ $minKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = CASE
WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey < :$minKey THEN COALESCE({$quotedColumn}, 0)
ELSE COALESCE({$quotedColumn}, 0) / :$bindKey
END";
}
+
return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) / :$bindKey";
- case Operator::TYPE_MODULO:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 1);
+ case OperatorType::Modulo:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = MOD(COALESCE({$quotedColumn}, 0), :$bindKey)";
- case Operator::TYPE_POWER:
+ case OperatorType::Power:
$exponent = $values[0] ?? 1;
- $bindKey = $this->registerOperatorBind($binds, $exponent);
+ if (! \is_int($exponent) && ! \is_float($exponent)) {
+ throw new OperatorException('Power exponent must be numeric');
+ }
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
if (isset($values[1])) {
- $maxKey = $this->registerOperatorBind($binds, $values[1]);
- $col = "COALESCE({$quotedColumn}, 0)";
+ $maxKey = "op_{$bindIndex}";
+ $bindIndex++;
- // Leave the value unchanged only for undefined inputs, then apply the power if
- // the result stays within the max. The exponent is constant, so only the
- // undefined guard its value can actually trigger is emitted.
+ $columnValue = "COALESCE({$quotedColumn}, 0)";
$oddInteger = \floor($exponent) == $exponent && ((int) $exponent) % 2 !== 0;
+ $guards = [];
- $whens = [];
if ($exponent < 0) {
- // 0 to a negative power is undefined (POWER would error / return NULL).
- $whens[] = "WHEN {$col} = 0 THEN {$col}";
+ $guards[] = "WHEN {$columnValue} = 0 THEN {$columnValue}";
}
if (\floor($exponent) != $exponent) {
- // A negative base to a fractional exponent is not a real number.
- $whens[] = "WHEN {$col} < 0 THEN {$col}";
+ $guards[] = "WHEN {$columnValue} < 0 THEN {$columnValue}";
}
- // Cap by magnitude via logarithms so POWER() never runs on a value that would
- // overflow (base^exp > max <=> exp * LOG(base) > LOG(max)).
if ($exponent == 0) {
- // Every base to the zeroth power is 1 (including 0^0), which the magnitude
- // check below can't see for a base of 0. The result 1 exceeds the max when
- // max < 1, i.e. LOG(max) < 0 (LOG also coerces the bound value numerically).
- $whens[] = "WHEN LOG(:$maxKey) < 0 THEN {$col}";
+ $guards[] = "WHEN LOG(:$maxKey) < 0 THEN {$columnValue}";
} elseif ($oddInteger) {
- // An odd exponent keeps a negative base negative, and a negative result is
- // always within a positive max, so only cap positive bases; negative bases
- // fall through to POWER() and their (negative) result is applied.
- $whens[] = "WHEN {$col} > 0 AND :$bindKey * LOG({$col}) > LOG(:$maxKey) THEN {$col}";
+ $guards[] = "WHEN {$columnValue} > 0 AND :$bindKey * LOG({$columnValue}) > LOG(:$maxKey) THEN {$columnValue}";
} else {
- // Otherwise the result is non-negative, so its magnitude equals its value —
- // cap either sign. ABS() keeps LOG() defined for a negative even-power base.
- $whens[] = "WHEN {$col} <> 0 AND :$bindKey * LOG(ABS({$col})) > LOG(:$maxKey) THEN {$col}";
+ $guards[] = "WHEN {$columnValue} <> 0 AND :$bindKey * LOG(ABS({$columnValue})) > LOG(:$maxKey) THEN {$columnValue}";
}
- $whenSql = \implode(' ', $whens);
- return "{$quotedColumn} = CASE {$whenSql} ELSE POWER({$col}, :$bindKey) END";
+ return "{$quotedColumn} = CASE ".\implode(' ', $guards)." ELSE POWER({$columnValue}, :$bindKey) END";
}
+
return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)";
// String operators
- case Operator::TYPE_STRING_CONCAT:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? '');
+ case OperatorType::StringConcat:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = CONCAT(COALESCE({$quotedColumn}, ''), :$bindKey)";
- case Operator::TYPE_STRING_REPLACE:
- $searchKey = $this->registerOperatorBind($binds, $values[0] ?? '');
- $replaceKey = $this->registerOperatorBind($binds, $values[1] ?? '');
+ case OperatorType::StringReplace:
+ $searchKey = "op_{$bindIndex}";
+ $bindIndex++;
+ $replaceKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = REPLACE({$quotedColumn}, :$searchKey, :$replaceKey)";
// Boolean operators
- case Operator::TYPE_TOGGLE:
+ case OperatorType::Toggle:
return "{$quotedColumn} = NOT COALESCE({$quotedColumn}, FALSE)";
// Array operators
- case Operator::TYPE_ARRAY_APPEND:
- $bindKey = $this->registerOperatorBind($binds, json_encode($values));
+ case OperatorType::ArrayAppend:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)";
- case Operator::TYPE_ARRAY_PREPEND:
- $bindKey = $this->registerOperatorBind($binds, json_encode($values));
+ case OperatorType::ArrayPrepend:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))";
- case Operator::TYPE_ARRAY_INSERT:
- $indexKey = $this->registerOperatorBind($binds, $values[0] ?? 0);
- $valueKey = $this->registerOperatorBind($binds, json_encode($values[1] ?? null));
+ case OperatorType::ArrayInsert:
+ $indexKey = "op_{$bindIndex}";
+ $bindIndex++;
+ $valueKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = JSON_ARRAY_INSERT(
{$quotedColumn},
CONCAT('$[', :$indexKey, ']'),
JSON_EXTRACT(:$valueKey, '$')
)";
- case Operator::TYPE_ARRAY_REMOVE:
- $removeValue = $values[0] ?? null;
- // Cast scalars to string so the value binds as PDO::PARAM_STR, preserving the
- // pre-refactor behavior (it was bound with an explicit PARAM_STR). JSON_TABLE
- // extracts `value` as TEXT, so the search term must compare as text — without
- // the cast, getPDOType() would bind a number as PARAM_INT. Do not drop it.
- $removeValue = is_array($removeValue) ? json_encode($removeValue) : (string)$removeValue;
- $bindKey = $this->registerOperatorBind($binds, $removeValue);
+ case OperatorType::ArrayRemove:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = IFNULL((
SELECT JSON_ARRAYAGG(value)
FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt
WHERE value != :$bindKey
), JSON_ARRAY())";
- case Operator::TYPE_ARRAY_UNIQUE:
+ case OperatorType::ArrayUnique:
return "{$quotedColumn} = IFNULL((
SELECT JSON_ARRAYAGG(DISTINCT jt.value)
FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt
), JSON_ARRAY())";
- case Operator::TYPE_ARRAY_INTERSECT:
- $bindKey = $this->registerOperatorBind($binds, json_encode($values));
+ case OperatorType::ArrayIntersect:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = IFNULL((
SELECT JSON_ARRAYAGG(jt1.value)
FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt1
@@ -2146,8 +1439,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
)
), JSON_ARRAY())";
- case Operator::TYPE_ARRAY_DIFF:
- $bindKey = $this->registerOperatorBind($binds, json_encode($values));
+ case OperatorType::ArrayDiff:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = IFNULL((
SELECT JSON_ARRAYAGG(jt1.value)
FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt1
@@ -2157,11 +1452,12 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
)
), JSON_ARRAY())";
- case Operator::TYPE_ARRAY_FILTER:
- $condition = $values[0] ?? 'equal';
- $filterValue = $values[1] ?? null;
- $conditionKey = $this->registerOperatorBind($binds, $condition);
- $valueKey = $this->registerOperatorBind($binds, $filterValue === null ? null : json_encode($filterValue));
+ case OperatorType::ArrayFilter:
+ $conditionKey = "op_{$bindIndex}";
+ $bindIndex++;
+ $valueKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = IFNULL((
SELECT JSON_ARRAYAGG(value)
FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt
@@ -2179,170 +1475,181 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi
), JSON_ARRAY())";
// Date operators
- case Operator::TYPE_DATE_ADD_DAYS:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0);
+ case OperatorType::DateAddDays:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = DATE_ADD({$quotedColumn}, INTERVAL :$bindKey DAY)";
- case Operator::TYPE_DATE_SUB_DAYS:
- $bindKey = $this->registerOperatorBind($binds, $values[0] ?? 0);
+ case OperatorType::DateSubDays:
+ $bindKey = "op_{$bindIndex}";
+ $bindIndex++;
+
return "{$quotedColumn} = DATE_SUB({$quotedColumn}, INTERVAL :$bindKey DAY)";
- case Operator::TYPE_DATE_SET_NOW:
+ case OperatorType::DateSetNow:
return "{$quotedColumn} = NOW()";
default:
- throw new OperatorException("Invalid operator: {$method}");
+ throw new OperatorException('Invalid operator');
}
}
- public function getSupportForNumericCasting(): bool
+ protected function getSearchRelevanceRaw(Query $query, string $alias): ?array
{
- return true;
- }
+ [$quotedAlias, $quotedAttribute] = $this->quoteSearchAttribute($query->getAttribute(), $alias);
+ $searchVal = $query->getValue();
+ $term = $this->getFulltextValue(\is_string($searchVal) ? $searchVal : '');
- public function getSupportForIndexArray(): bool
- {
- return true;
+ return [
+ 'expression' => "MATCH({$quotedAlias}.{$quotedAttribute}) AGAINST (? IN BOOLEAN MODE) AS `_relevance`",
+ 'order' => '`_relevance` DESC',
+ 'bindings' => [$term],
+ ];
}
- public function getSupportForSpatialAttributes(): bool
+ public function getSchemaIndexes(string $collection): array
{
- return true;
- }
+ $schema = $this->getDatabase();
+ $collection = $this->getNamespace() . '_' . $this->filter($collection);
- public function getSupportForObject(): bool
- {
- return false;
- }
+ try {
+ $stmt = $this->prepareStatement('
+ SELECT
+ INDEX_NAME as indexName,
+ COLUMN_NAME as columnName,
+ NON_UNIQUE as nonUnique,
+ SEQ_IN_INDEX as seqInIndex,
+ INDEX_TYPE as indexType,
+ SUB_PART as subPart
+ FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
+ ORDER BY INDEX_NAME, SEQ_IN_INDEX
+ ', Event::CollectionRead);
+ $stmt->bindParam(':schema', $schema);
+ $stmt->bindParam(':table', $collection);
+ $this->execute($stmt);
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ $stmt->closeCursor();
- public function getSupportForUnsignedBigInt(): bool
- {
- return true;
- }
+ $grouped = [];
+ foreach ($rows as $row) {
+ if (! \is_array($row)) {
+ continue;
+ }
+ $name = \is_string($row['indexName'] ?? null) ? $row['indexName'] : '';
+ if ($name === '') {
+ continue;
+ }
+ if (!isset($grouped[$name])) {
+ $indexType = \is_string($row['indexType'] ?? null) ? $row['indexType'] : '';
+ $nonUnique = \is_numeric($row['nonUnique'] ?? null) ? (int) $row['nonUnique'] : 0;
+ $grouped[$name] = [
+ Document::ID => $name,
+ 'indexName' => $name,
+ 'indexType' => $indexType,
+ 'nonUnique' => $nonUnique,
+ 'columns' => [],
+ 'lengths' => [],
+ ];
+ }
+ $grouped[$name]['columns'][] = \is_string($row['columnName'] ?? null) ? $row['columnName'] : '';
+ $subPart = $row['subPart'] ?? null;
+ $grouped[$name]['lengths'][] = \is_numeric($subPart) ? (int) $subPart : null;
+ }
- /**
- * Are object (JSON) indexes supported?
- *
- * @return bool
- */
- public function getSupportForObjectIndexes(): bool
- {
- return false;
+ return \array_map(fn ($idx) => new Document($idx), \array_values($grouped));
+ } catch (PDOException $e) {
+ throw new DatabaseException('Failed to get schema indexes', $e->getCode(), $e);
+ }
}
- /**
- * Get Support for Null Values in Spatial Indexes
- *
- * @return bool
- */
- public function getSupportForSpatialIndexNull(): bool
+ protected function processException(PDOException $e): Exception
{
- return false;
- }
- /**
- * Does the adapter includes boundary during spatial contains?
- *
- * @return bool
- */
+ if ($e->getCode() === '22007' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1366) {
+ return new CharacterException('Invalid character', $e->getCode(), $e);
+ }
- public function getSupportForBoundaryInclusiveContains(): bool
- {
- return true;
- }
- /**
- * Does the adapter support order attribute in spatial indexes?
- *
- * @return bool
- */
- public function getSupportForSpatialIndexOrder(): bool
- {
- return true;
- }
+ // Timeout
+ if ($e->getCode() === '70100' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1969) {
+ return new TimeoutException('Query timed out', $e->getCode(), $e);
+ }
- /**
- * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)?
- *
- * @return bool
- */
- public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool
- {
- return false;
- }
+ // Duplicate table
+ if ($e->getCode() === '42S01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1050) {
+ return new DuplicateException('Collection already exists', $e->getCode(), $e);
+ }
- public function getSpatialSQLType(string $type, bool $required): string
- {
- $srid = Database::DEFAULT_SRID;
- $nullability = '';
+ // Duplicate column
+ if ($e->getCode() === '42S21' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1060) {
+ return new DuplicateException('Attribute already exists', $e->getCode(), $e);
+ }
- if (!$this->getSupportForSpatialIndexNull()) {
- if ($required) {
- $nullability = ' NOT NULL';
- } else {
- $nullability = ' NULL';
- }
+ // Duplicate index
+ if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1061) {
+ return new DuplicateException('Index already exists', $e->getCode(), $e);
}
- switch ($type) {
- case Database::VAR_POINT:
- return "POINT($srid)$nullability";
+ // Duplicate row
+ if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) {
+ $key = $this->getViolatedKey($e->getMessage());
+ if ($key === Storage::INDEX_1) {
+ return new DuplicateException('Duplicate permissions for document', $e->getCode(), $e);
+ }
+ if ($key !== null && $key !== Storage::UID && $key !== 'PRIMARY') {
+ return new UniqueException('Unique index violation', $e->getCode(), $e);
+ }
- case Database::VAR_LINESTRING:
- return "LINESTRING($srid)$nullability";
+ return new DuplicateException('Document already exists', $e->getCode(), $e);
+ }
- case Database::VAR_POLYGON:
- return "POLYGON($srid)$nullability";
+ // Data is too big for column resize
+ if (($e->getCode() === '22001' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1406) ||
+ ($e->getCode() === '01000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1265)) {
+ return new TruncateException('Resize would result in data truncation', $e->getCode(), $e);
}
- return '';
- }
+ // Numeric value out of range
+ if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1264 || $e->errorInfo[1] === 1690)) {
+ return new LimitException('Value out of range', $e->getCode(), $e);
+ }
- /**
- * Does the adapter support spatial axis order specification?
- *
- * @return bool
- */
- public function getSupportForSpatialAxisOrder(): bool
- {
- return false;
- }
+ // Numeric value out of range
+ if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1690) {
+ return new LimitException('Value is out of range', $e->getCode(), $e);
+ }
- /**
- * Adapter supports optional spatial attributes with existing rows.
- *
- * @return bool
- */
- public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool
- {
- return true;
- }
+ // Unknown database
+ if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) {
+ return new NotFoundException('Database not found', $e->getCode(), $e);
+ }
- public function getSupportForAlterLocks(): bool
- {
- return true;
- }
+ // Unknown collection
+ if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) {
+ return new NotFoundException('Collection not found', $e->getCode(), $e);
+ }
- public function getSupportNonUtfCharacters(): bool
- {
- return true;
- }
+ // Unknown collection
+ // We have two of same, because docs point to 1051.
+ // Keeping previous 1049 (above) just in case it's for older versions
+ if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1051) {
+ return new NotFoundException('Collection not found', $e->getCode(), $e);
+ }
- public function getSupportForTrigramIndex(): bool
- {
- return false;
- }
+ // Unknown column
+ if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) {
+ return new NotFoundException('Attribute not found', $e->getCode(), $e);
+ }
- public function getSupportForPCRERegex(): bool
- {
- return true;
+ return $e;
}
- public function getSupportForPOSIXRegex(): bool
+ protected function getViolatedKey(string $message): ?string
{
- return false;
- }
+ if (\preg_match("/for key '(?:[^'.]*\.)?([^']+)'/", $message, $matches) !== 1) {
+ return null;
+ }
- public function getSupportForTTLIndexes(): bool
- {
- return false;
+ return $matches[1];
}
}
diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php
index 5e126a7177..a91b3e54b5 100644
--- a/src/Database/Adapter/Memory.php
+++ b/src/Database/Adapter/Memory.php
@@ -3,6 +3,8 @@
namespace Utopia\Database\Adapter;
use Utopia\Database\Adapter;
+use Utopia\Database\Attribute;
+use Utopia\Database\Capability;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
@@ -10,10 +12,22 @@
use Utopia\Database\Exception\Duplicate as DuplicateException;
use Utopia\Database\Exception\Limit as LimitException;
use Utopia\Database\Exception\NotFound as NotFoundException;
-use Utopia\Database\Exception\Operator as OperatorException;
use Utopia\Database\Exception\Unique as UniqueException;
+use Utopia\Database\Index;
use Utopia\Database\Operator;
+use Utopia\Database\OperatorType;
+use Utopia\Database\PermissionType;
use Utopia\Database\Query;
+use Utopia\Database\Relationship;
+use Utopia\Database\RelationSide;
+use Utopia\Database\RelationType;
+use Utopia\Database\Storage;
+use Utopia\Database\Validator\BigInt;
+use Utopia\Query\CursorDirection;
+use Utopia\Query\Method;
+use Utopia\Query\OrderDirection;
+use Utopia\Query\Schema\ColumnType;
+use Utopia\Query\Schema\IndexType;
/**
* In-process drop-in for the SQL adapters that keeps all data in PHP
@@ -28,7 +42,7 @@
* Spatial types and vector search throw a DatabaseException — those
* features only make sense against a real engine.
*/
-class Memory extends Adapter
+class Memory extends Adapter implements Feature\Relationships
{
/**
* Map of database name to the set of collection storage keys it owns.
@@ -101,6 +115,32 @@ public function getDriver(): mixed
return 'memory';
}
+ /**
+ * @return array
+ */
+ public function capabilities(): array
+ {
+ return array_merge(parent::capabilities(), [
+ Capability::Schemas,
+ Capability::Fulltext,
+ Capability::Casting,
+ Capability::QueryContains,
+ Capability::BatchOperations,
+ Capability::BatchCreateAttributes,
+ Capability::AttributeResizing,
+ Capability::Objects,
+ Capability::ObjectIndexes,
+ Capability::Operators,
+ Capability::OrderRandom,
+ Capability::DefinedAttributes,
+ Capability::NestedTransactions,
+ Capability::PCRE,
+ Capability::Regex,
+ Capability::BoundaryInclusive,
+ Capability::Caching,
+ ]);
+ }
+
protected function key(string $collection): string
{
// Schema scoping: prefix the storage key with the current database
@@ -137,9 +177,11 @@ protected function locateDocument(string $key, string $collectionId, string $id)
if ($this->sharedTables && $collectionId === Database::METADATA) {
$lower = \strtolower($id);
foreach ($this->data[$key]['documents'] as $storageKey => $candidate) {
+ $uid = $candidate[Storage::UID] ?? '';
if (
- \strtolower((string) ($candidate['_uid'] ?? '')) === $lower
- && ($candidate['_tenant'] ?? null) === null
+ \is_string($uid)
+ && \strtolower($uid) === $lower
+ && ($candidate[Storage::TENANT] ?? null) === null
) {
return [$storageKey, $candidate];
}
@@ -149,11 +191,6 @@ protected function locateDocument(string $key, string $collectionId, string $id)
return null;
}
- public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void
- {
- // No-op: nothing to time out in-memory
- }
-
public function ping(): bool
{
return true;
@@ -360,22 +397,22 @@ public function createCollection(string $name, array $attributes = [], array $in
}
foreach ($attributes as $attribute) {
- $attrId = $this->filter($attribute->getId());
+ $attrId = $this->filter($attribute->key);
$this->data[$key]['attributes'][$attrId] = [
- 'type' => $attribute->getAttribute('type'),
- 'size' => $attribute->getAttribute('size', 0),
- 'signed' => $attribute->getAttribute('signed', true),
- 'array' => $attribute->getAttribute('array', false),
- 'required' => $attribute->getAttribute('required', false),
+ 'type' => $attribute->type->value,
+ 'size' => $attribute->size,
+ 'signed' => $attribute->signed,
+ 'array' => $attribute->array,
+ 'required' => $attribute->required,
];
}
foreach ($indexes as $index) {
- $indexId = $this->filter($index->getId());
+ $indexId = $this->filter($index->key);
$this->data[$key]['indexes'][$indexId] = [
- 'type' => $index->getAttribute('type'),
- 'attributes' => $index->getAttribute('attributes', []),
- 'lengths' => $index->getAttribute('lengths', []),
+ 'type' => $index->type->value,
+ 'attributes' => $index->attributes,
+ 'lengths' => $index->lengths,
'orders' => $index->getAttribute('orders', []),
];
}
@@ -388,7 +425,7 @@ public function createCollection(string $name, array $attributes = [], array $in
$this->permissionsByPermission[$key],
$this->uniqueIndexHashes[$key],
);
- if ($database !== '' && $databaseSlot !== null) {
+ if ($databaseSlot !== null) {
unset($this->databases[$database][$databaseSlot]);
}
});
@@ -450,21 +487,21 @@ public function analyzeCollection(string $collection): bool
return false;
}
- public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool
+ public function createAttribute(string $collection, Attribute $attribute): bool
{
$key = $this->key($collection);
if (! isset($this->data[$key])) {
throw new NotFoundException('Collection not found');
}
- $id = $this->filter($id);
+ $id = $this->filter($attribute->key);
$previous = $this->data[$key]['attributes'][$id] ?? null;
$this->data[$key]['attributes'][$id] = [
- 'type' => $type,
- 'size' => $size,
- 'signed' => $signed,
- 'array' => $array,
- 'required' => $required,
+ 'type' => $attribute->type->value,
+ 'size' => $attribute->size,
+ 'signed' => $attribute->signed,
+ 'array' => $attribute->array,
+ 'required' => $attribute->required,
];
$this->journal(function () use ($key, $id, $previous): void {
@@ -481,28 +518,20 @@ public function createAttribute(string $collection, string $id, string $type, in
public function createAttributes(string $collection, array $attributes): bool
{
foreach ($attributes as $attribute) {
- $this->createAttribute(
- $collection,
- (string) $attribute['$id'],
- (string) $attribute['type'],
- (int) ($attribute['size'] ?? 0),
- (bool) ($attribute['signed'] ?? true),
- (bool) ($attribute['array'] ?? false),
- (bool) ($attribute['required'] ?? false),
- );
+ $this->createAttribute($collection, $attribute);
}
return true;
}
- public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool
+ public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool
{
$key = $this->key($collection);
if (! isset($this->data[$key])) {
throw new NotFoundException('Collection not found');
}
- $id = $this->filter($id);
+ $id = $this->filter($attribute->key);
if (! empty($newKey) && $newKey !== $id) {
$this->renameAttribute($collection, $id, $newKey);
$id = $this->filter($newKey);
@@ -510,11 +539,11 @@ public function updateAttribute(string $collection, string $id, string $type, in
$previous = $this->data[$key]['attributes'][$id] ?? null;
$this->data[$key]['attributes'][$id] = [
- 'type' => $type,
- 'size' => $size,
- 'signed' => $signed,
- 'array' => $array,
- 'required' => $required,
+ 'type' => $attribute->type->value,
+ 'size' => $attribute->size,
+ 'signed' => $attribute->signed,
+ 'array' => $attribute->array,
+ 'required' => $attribute->required,
];
$this->journal(function () use ($key, $id, $previous): void {
@@ -554,29 +583,34 @@ public function deleteAttribute(string $collection, string $id): bool
$previousIndexes = [];
$previousUniqueHashes = [];
- foreach ($this->data[$key]['indexes'] as $indexId => &$index) {
- $attributes = $index['attributes'] ?? [];
+ foreach ($this->data[$key]['indexes'] as $indexId => $index) {
+ $attributes = \is_array($index['attributes'] ?? null) ? $index['attributes'] : [];
+ $indexLengths = \is_array($index['lengths'] ?? null) ? $index['lengths'] : [];
+ $indexOrders = \is_array($index['orders'] ?? null) ? $index['orders'] : [];
$filtered = [];
$lengths = [];
$orders = [];
$touched = false;
foreach ($attributes as $i => $attribute) {
+ if (! \is_string($attribute)) {
+ continue;
+ }
if ($this->filter($attribute) === $id) {
$touched = true;
continue;
}
$filtered[] = $attribute;
- if (isset($index['lengths'][$i])) {
- $lengths[] = $index['lengths'][$i];
+ if (isset($indexLengths[$i])) {
+ $lengths[] = $indexLengths[$i];
}
- if (isset($index['orders'][$i])) {
- $orders[] = $index['orders'][$i];
+ if (isset($indexOrders[$i])) {
+ $orders[] = $indexOrders[$i];
}
}
if ($touched) {
$previousIndexes[$indexId] = $index;
- if (($index['type'] ?? '') === Database::INDEX_UNIQUE
+ if (($index['type'] ?? '') === IndexType::Unique->value
&& isset($this->uniqueIndexHashes[$key][$indexId])) {
$previousUniqueHashes[$indexId] = $this->uniqueIndexHashes[$key][$indexId];
unset($this->uniqueIndexHashes[$key][$indexId]);
@@ -585,21 +619,24 @@ public function deleteAttribute(string $collection, string $id): bool
$index['attributes'] = $filtered;
$index['lengths'] = $lengths;
$index['orders'] = $orders;
+ $this->data[$key]['indexes'][$indexId] = $index;
}
- unset($index);
$this->journal(function () use ($key, $id, $previousAttribute, $previousValues, $previousIndexes, $previousUniqueHashes): void {
+ if (! isset($this->data[$key])) {
+ return;
+ }
$this->data[$key]['attributes'][$id] = $previousAttribute;
foreach ($previousValues as $storageKey => $value) {
if (isset($this->data[$key]['documents'][$storageKey])) {
$this->data[$key]['documents'][$storageKey][$id] = $value;
}
}
- foreach ($previousIndexes as $indexId => $value) {
- $this->data[$key]['indexes'][$indexId] = $value;
+ foreach ($previousIndexes as $indexId => $previousIndex) {
+ $this->data[$key]['indexes'][$indexId] = $previousIndex;
}
- foreach ($previousUniqueHashes as $indexId => $value) {
- $this->uniqueIndexHashes[$key][$indexId] = $value;
+ foreach ($previousUniqueHashes as $indexId => $hashes) {
+ $this->uniqueIndexHashes[$key][$indexId] = $hashes;
}
});
@@ -635,10 +672,10 @@ public function renameAttribute(string $collection, string $old, string $new): b
$touchedIndexes = [];
foreach ($this->data[$key]['indexes'] as $indexId => &$index) {
- $attributes = $index['attributes'] ?? [];
+ $attributes = \is_array($index['attributes'] ?? null) ? $index['attributes'] : [];
$changed = false;
foreach ($attributes as $i => $attribute) {
- if ($this->filter($attribute) === $old) {
+ if (\is_string($attribute) && $this->filter($attribute) === $old) {
$attributes[$i] = $new;
$changed = true;
}
@@ -651,32 +688,40 @@ public function renameAttribute(string $collection, string $old, string $new): b
unset($index);
$this->journal(function () use ($key, $old, $new, $touchedDocs, $touchedIndexes): void {
- $this->data[$key]['attributes'][$old] = $this->data[$key]['attributes'][$new];
- unset($this->data[$key]['attributes'][$new]);
+ if (! isset($this->data[$key])) {
+ return;
+ }
+ $entry = &$this->data[$key];
+ $entry['attributes'][$old] = $entry['attributes'][$new];
+ unset($entry['attributes'][$new]);
foreach ($touchedDocs as $storageKey) {
- if (! isset($this->data[$key]['documents'][$storageKey])) {
+ if (! isset($entry['documents'][$storageKey])) {
continue;
}
- $document = &$this->data[$key]['documents'][$storageKey];
+ $document = &$entry['documents'][$storageKey];
$document[$old] = $document[$new];
unset($document[$new]);
unset($document);
}
foreach ($touchedIndexes as $indexId) {
- $attributes = $this->data[$key]['indexes'][$indexId]['attributes'] ?? [];
+ $attributes = \is_array($entry['indexes'][$indexId]['attributes'] ?? null)
+ ? $entry['indexes'][$indexId]['attributes']
+ : [];
foreach ($attributes as $i => $attribute) {
- if ($this->filter($attribute) === $new) {
+ if (\is_string($attribute) && $this->filter($attribute) === $new) {
$attributes[$i] = $old;
}
}
- $this->data[$key]['indexes'][$indexId]['attributes'] = $attributes;
+ $entry['indexes'][$indexId]['attributes'] = $attributes;
}
+ unset($entry);
});
return true;
}
- public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool
+ #[\Override]
+ public function createRelationship(Relationship $relationship): bool
{
// Memory stores documents as flexible maps, so the relationship "column"
// is registered on the attribute list rather than added as a physical
@@ -685,20 +730,26 @@ public function createRelationship(string $collection, string $relatedCollection
// which selects the column even when no rows have a value.
// The M2M junction collection itself is created by the wrapper through
// the standard createCollection path.
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
+ $collection = $relationship->collection;
+ $relatedCollection = $relationship->relatedCollection;
+ $id = $relationship->key;
+ $twoWayKey = $relationship->twoWayKey;
+ $twoWay = $relationship->twoWay;
+
+ switch ($relationship->type) {
+ case RelationType::OneToOne:
$this->registerRelationshipField($collection, $id);
if ($twoWay) {
$this->registerRelationshipField($relatedCollection, $twoWayKey);
}
break;
- case Database::RELATION_ONE_TO_MANY:
+ case RelationType::OneToMany:
$this->registerRelationshipField($relatedCollection, $twoWayKey);
break;
- case Database::RELATION_MANY_TO_ONE:
+ case RelationType::ManyToOne:
$this->registerRelationshipField($collection, $id);
break;
- case Database::RELATION_MANY_TO_MANY:
+ case RelationType::ManyToMany:
// Junction columns live on the junction collection, which is
// created with explicit attributes by the wrapper.
break;
@@ -709,15 +760,20 @@ public function createRelationship(string $collection, string $relatedCollection
return true;
}
- public function updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool
+ #[\Override]
+ public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): bool
{
- $key = $this->filter($key);
- $twoWayKey = $this->filter($twoWayKey);
+ $collection = $relationship->collection;
+ $relatedCollection = $relationship->relatedCollection;
+ $key = $this->filter($relationship->key);
+ $twoWayKey = $this->filter($relationship->twoWayKey);
$newKey = $newKey !== null ? $this->filter($newKey) : null;
$newTwoWayKey = $newTwoWayKey !== null ? $this->filter($newTwoWayKey) : null;
+ $side = $relationship->side;
+ $twoWay = $relationship->twoWay;
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
+ switch ($relationship->type) {
+ case RelationType::OneToOne:
if ($newKey !== null && $newKey !== $key) {
$this->renameDocumentField($collection, $key, $newKey);
}
@@ -725,8 +781,8 @@ public function updateRelationship(string $collection, string $relatedCollection
$this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey);
}
break;
- case Database::RELATION_ONE_TO_MANY:
- if ($side === Database::RELATION_SIDE_PARENT) {
+ case RelationType::OneToMany:
+ if ($side === RelationSide::Parent) {
if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) {
$this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey);
}
@@ -736,8 +792,8 @@ public function updateRelationship(string $collection, string $relatedCollection
}
}
break;
- case Database::RELATION_MANY_TO_ONE:
- if ($side === Database::RELATION_SIDE_CHILD) {
+ case RelationType::ManyToOne:
+ if ($side === RelationSide::Child) {
if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) {
$this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey);
}
@@ -747,7 +803,7 @@ public function updateRelationship(string $collection, string $relatedCollection
}
}
break;
- case Database::RELATION_MANY_TO_MANY:
+ case RelationType::ManyToMany:
$junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side);
if ($junction !== null) {
if ($newKey !== null && $newKey !== $key) {
@@ -765,14 +821,19 @@ public function updateRelationship(string $collection, string $relatedCollection
return true;
}
- public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool
+ #[\Override]
+ public function deleteRelationship(Relationship $relationship): bool
{
- $key = $this->filter($key);
- $twoWayKey = $this->filter($twoWayKey);
+ $collection = $relationship->collection;
+ $relatedCollection = $relationship->relatedCollection;
+ $key = $this->filter($relationship->key);
+ $twoWayKey = $this->filter($relationship->twoWayKey);
+ $twoWay = $relationship->twoWay;
+ $side = $relationship->side;
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
- if ($side === Database::RELATION_SIDE_PARENT) {
+ switch ($relationship->type) {
+ case RelationType::OneToOne:
+ if ($side === RelationSide::Parent) {
$this->dropDocumentField($collection, $key);
if ($twoWay) {
$this->dropDocumentField($relatedCollection, $twoWayKey);
@@ -784,21 +845,21 @@ public function deleteRelationship(string $collection, string $relatedCollection
}
}
break;
- case Database::RELATION_ONE_TO_MANY:
- if ($side === Database::RELATION_SIDE_PARENT) {
+ case RelationType::OneToMany:
+ if ($side === RelationSide::Parent) {
$this->dropDocumentField($relatedCollection, $twoWayKey);
} else {
$this->dropDocumentField($collection, $key);
}
break;
- case Database::RELATION_MANY_TO_ONE:
- if ($side === Database::RELATION_SIDE_PARENT) {
+ case RelationType::ManyToOne:
+ if ($side === RelationSide::Parent) {
$this->dropDocumentField($collection, $key);
} else {
$this->dropDocumentField($relatedCollection, $twoWayKey);
}
break;
- case Database::RELATION_MANY_TO_MANY:
+ case RelationType::ManyToMany:
// Junction collection is dropped by the wrapper via cleanupCollection.
break;
default:
@@ -822,7 +883,7 @@ protected function registerRelationshipField(string $collection, string $field):
$field = $this->filter($field);
$previous = $this->data[$key]['attributes'][$field] ?? null;
$this->data[$key]['attributes'][$field] = [
- 'type' => Database::VAR_RELATIONSHIP,
+ 'type' => ColumnType::Relationship->value,
'size' => 0,
'signed' => true,
'array' => false,
@@ -935,7 +996,7 @@ protected function dropDocumentField(string $collection, string $field): void
* Mirrors Database::getJunctionCollection — the junction is named after
* the parent/child sequence pair.
*/
- protected function resolveJunctionCollection(string $collection, string $relatedCollection, string $side): ?string
+ protected function resolveJunctionCollection(string $collection, string $relatedCollection, RelationSide $side): ?string
{
$metadataKey = $this->key(Database::METADATA);
if (! isset($this->data[$metadataKey])) {
@@ -948,13 +1009,13 @@ protected function resolveJunctionCollection(string $collection, string $related
return null;
}
- $collectionSequence = $collectionDoc[1]['_id'] ?? null;
- $relatedSequence = $relatedDoc[1]['_id'] ?? null;
- if ($collectionSequence === null || $relatedSequence === null) {
+ $collectionSequence = $collectionDoc[1][Storage::SEQUENCE] ?? null;
+ $relatedSequence = $relatedDoc[1][Storage::SEQUENCE] ?? null;
+ if (! \is_scalar($collectionSequence) || ! \is_scalar($relatedSequence)) {
return null;
}
- return $side === Database::RELATION_SIDE_PARENT
+ return $side === RelationSide::Parent
? '_'.$collectionSequence.'_'.$relatedSequence
: '_'.$relatedSequence.'_'.$collectionSequence;
}
@@ -995,15 +1056,21 @@ public function renameIndex(string $collection, string $old, string $new): bool
return true;
}
- public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool
+ public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool
{
$key = $this->key($collection);
if (! isset($this->data[$key])) {
throw new NotFoundException('Collection not found');
}
+ $id = $index->key;
+ $type = $index->type->value;
+ $attributes = $index->attributes;
+ $lengths = $index->lengths;
+ $orders = $index->getAttribute('orders', []);
+
$hashTable = [];
- if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) {
+ if ($type === IndexType::Unique->value && ! empty($attributes)) {
// MariaDB rejects CREATE UNIQUE INDEX with errno 1062 when existing
// rows contain duplicates; Database::createIndex catches the resulting
// DuplicateException and treats it as an "orphan index" (the metadata
@@ -1022,7 +1089,7 @@ public function createIndex(string $collection, string $id, string $type, array
continue;
}
if ($this->sharedTables) {
- \array_unshift($signature, $row['_tenant'] ?? null);
+ \array_unshift($signature, $row[Storage::TENANT] ?? null);
}
$hash = \serialize($signature);
if (isset($hashTable[$hash])) {
@@ -1039,13 +1106,13 @@ public function createIndex(string $collection, string $id, string $type, array
'lengths' => $lengths,
'orders' => $orders,
];
- if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) {
+ if ($type === IndexType::Unique->value && ! empty($attributes)) {
$this->uniqueIndexHashes[$key][$id] = $hashTable;
}
$this->journal(function () use ($key, $id, $type): void {
unset($this->data[$key]['indexes'][$id]);
- if ($type === Database::INDEX_UNIQUE) {
+ if ($type === IndexType::Unique->value) {
unset($this->uniqueIndexHashes[$key][$id]);
}
});
@@ -1113,12 +1180,11 @@ private function getSelectAttributes(array $queries): array
{
$selected = [];
foreach ($queries as $query) {
- if (! $query instanceof Query) {
- continue;
- }
- if ($query->getMethod() === Query::TYPE_SELECT) {
+ if ($query->getMethod() === Method::Select) {
foreach ($query->getValues() as $value) {
- $selected[] = (string) $value;
+ if (\is_string($value)) {
+ $selected[] = $value;
+ }
}
}
}
@@ -1168,7 +1234,8 @@ public function createDocument(Document $collection, Document $document): Docume
// Mirrors MariaDB's `INSERT IGNORE` — duplicate primary key is
// silently dropped and the existing row's sequence is returned.
$existing = $this->data[$key]['documents'][$docKey];
- $document['$sequence'] = (string) $existing['_id'];
+ $existingId = $existing[Storage::SEQUENCE] ?? '';
+ $document[Document::SEQUENCE] = \is_scalar($existingId) ? (string) $existingId : '';
return $document;
}
@@ -1185,22 +1252,24 @@ public function createDocument(Document $collection, Document $document): Docume
throw $e;
}
- $sequenceBefore = $this->data[$key]['sequence'];
+ $entry = &$this->data[$key];
+ $sequenceBefore = $entry['sequence'];
$sequence = $document->getSequence();
if (empty($sequence)) {
- $this->data[$key]['sequence']++;
- $sequence = $this->data[$key]['sequence'];
+ $entry['sequence']++;
+ $sequence = $entry['sequence'];
} else {
$sequence = (int) $sequence;
- if ($sequence > $this->data[$key]['sequence']) {
- $this->data[$key]['sequence'] = $sequence;
+ if ($sequence > $entry['sequence']) {
+ $entry['sequence'] = $sequence;
}
}
$row = $this->documentToRow($document);
- $row['_id'] = $sequence;
+ $row[Storage::SEQUENCE] = $sequence;
- $this->data[$key]['documents'][$docKey] = $row;
+ $entry['documents'][$docKey] = $row;
+ unset($entry);
$this->journal(function () use ($key, $docKey, $sequenceBefore): void {
unset($this->data[$key]['documents'][$docKey]);
$this->data[$key]['sequence'] = $sequenceBefore;
@@ -1212,13 +1281,27 @@ public function createDocument(Document $collection, Document $document): Docume
$this->writePermissions($key, $document);
- $document['$sequence'] = (string) $sequence;
+ $document[Document::SEQUENCE] = (string) $sequence;
return $document;
}
public function createDocuments(Document $collection, array $documents): array
{
+ // Mirror SQL's batch-level sequence consistency check: every document
+ // in a batch must either set $sequence or omit it. SQL adapters reject
+ // mixed batches up front; Memory must match so application code that
+ // catches the resulting DatabaseException behaves the same.
+ $hasSequence = null;
+ foreach ($documents as $document) {
+ $sequenceSet = ! empty($document->getSequence());
+ if ($hasSequence === null) {
+ $hasSequence = $sequenceSet;
+ } elseif ($hasSequence !== $sequenceSet) {
+ throw new DatabaseException('All documents must have an sequence if one is set');
+ }
+ }
+
$created = [];
foreach ($documents as $document) {
$created[] = $this->createDocument($collection, $document);
@@ -1269,38 +1352,45 @@ public function updateDocument(Document $collection, string $id, Document $docum
$oldSignatures = $this->rowUniqueSignatures($key, $existing);
$this->checkUniqueSignatures($key, $newSignatures, $oldKey);
- $row['_id'] = $existing['_id'];
- if ($this->sharedTables && \array_key_exists('_tenant', $existing)) {
+ $row[Storage::SEQUENCE] = $existing[Storage::SEQUENCE];
+ if ($this->sharedTables && \array_key_exists(Storage::TENANT, $existing)) {
// Preserve the row's stored tenant — MariaDB's UPDATE statements
// never rewrite `_tenant` and tests rely on the original tenant
// (e.g. the metadata NULL-tenant rows) surviving an update.
- $row['_tenant'] = $existing['_tenant'];
+ $row[Storage::TENANT] = $existing[Storage::TENANT];
}
+ $tenantValue = $existing[Storage::TENANT] ?? $this->getTenant();
$newKey = $this->sharedTables
- ? ($existing['_tenant'] ?? $this->getTenant()).'|'.\strtolower($newId)
+ ? (\is_scalar($tenantValue) ? (string) $tenantValue : '').'|'.\strtolower($newId)
: \strtolower($newId);
- $oldKeyHadRow = isset($this->data[$key]['documents'][$oldKey]);
- $previousAtNewKey = $this->data[$key]['documents'][$newKey] ?? null;
+ $entry = &$this->data[$key];
+ $oldKeyHadRow = isset($entry['documents'][$oldKey]);
+ $previousAtNewKey = $entry['documents'][$newKey] ?? null;
if ($newId !== $id || $newKey !== $oldKey) {
- unset($this->data[$key]['documents'][$oldKey]);
+ unset($entry['documents'][$oldKey]);
}
- $this->data[$key]['documents'][$newKey] = $row;
+ $entry['documents'][$newKey] = $row;
+ unset($entry);
$this->journal(function () use ($key, $oldKey, $newKey, $existing, $oldKeyHadRow, $previousAtNewKey): void {
+ if (! isset($this->data[$key])) {
+ return;
+ }
+ $entry = &$this->data[$key];
if ($oldKey !== $newKey) {
if ($previousAtNewKey === null) {
- unset($this->data[$key]['documents'][$newKey]);
+ unset($entry['documents'][$newKey]);
} else {
- $this->data[$key]['documents'][$newKey] = $previousAtNewKey;
+ $entry['documents'][$newKey] = $previousAtNewKey;
}
if ($oldKeyHadRow) {
- $this->data[$key]['documents'][$oldKey] = $existing;
+ $entry['documents'][$oldKey] = $existing;
}
} else {
- $this->data[$key]['documents'][$oldKey] = $existing;
+ $entry['documents'][$oldKey] = $existing;
}
});
@@ -1380,7 +1470,7 @@ public function updateDocuments(Document $collection, Document $updates, array $
$attrs = $updates->getAttributes();
$hasCreatedAt = ! empty($updates->getCreatedAt());
$hasUpdatedAt = ! empty($updates->getUpdatedAt());
- $hasPermissions = $updates->offsetExists('$permissions');
+ $hasPermissions = $updates->offsetExists(Document::PERMISSIONS);
if (empty($attrs) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) {
return 0;
}
@@ -1407,7 +1497,7 @@ public function updateDocuments(Document $collection, Document $updates, array $
? new Document(\array_merge(
$this->rowToDocument($existingRow),
$resolvedAttrs,
- ['$id' => $uid]
+ [Document::ID => $uid]
))
: null;
@@ -1468,13 +1558,13 @@ public function updateDocuments(Document $collection, Document $updates, array $
}
if ($hasCreatedAt) {
- $row['_createdAt'] = $updates->getCreatedAt();
+ $row[Storage::CREATED_AT] = $updates->getCreatedAt();
}
if ($hasUpdatedAt) {
- $row['_updatedAt'] = $updates->getUpdatedAt();
+ $row[Storage::UPDATED_AT] = $updates->getUpdatedAt();
}
if ($hasPermissions) {
- $row['_permissions'] = $updates->getPermissions();
+ $row[Storage::PERMISSIONS] = $updates->getPermissions();
}
unset($row);
@@ -1484,9 +1574,9 @@ public function updateDocuments(Document $collection, Document $updates, array $
if ($hasPermissions) {
$this->removePermissionsForDocument($key, $uid, $tenant, $this->sharedTables);
- foreach (Database::PERMISSIONS as $type) {
+ foreach ([PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete] as $type) {
foreach ($updates->getPermissionsByType($type) as $permission) {
- $this->addPermissionEntry($key, $uid, (string) $type, (string) $permission, $tenant);
+ $this->addPermissionEntry($key, $uid, $type->value, (string) $permission, $tenant);
}
}
}
@@ -1507,11 +1597,6 @@ public function updateDocuments(Document $collection, Document $updates, array $
return \count($prepared);
}
- public function upsertDocuments(Document $collection, string $attribute, array $changes): array
- {
- throw new DatabaseException('Upsert is not implemented in the Memory adapter');
- }
-
public function getSequences(string $collection, array $documents): array
{
$key = $this->key($collection);
@@ -1527,7 +1612,8 @@ public function getSequences(string $collection, array $documents): array
// — the lookup must use each document's own tenant, not the adapter's current tenant.
$existing = $this->data[$key]['documents'][$this->documentKey($doc->getId(), $doc->getTenant())] ?? null;
if ($existing !== null) {
- $documents[$index]->setAttribute('$sequence', (string) $existing['_id']);
+ $existingId = $existing[Storage::SEQUENCE] ?? '';
+ $documents[$index]->setAttribute(Document::SEQUENCE, \is_scalar($existingId) ? (string) $existingId : '');
}
}
@@ -1591,11 +1677,13 @@ public function deleteDocuments(string $collection, array $sequences, array $per
// With sharedTables the row map is keyed by "tenant|uid" so sequence
// collisions across tenants are possible. Skip rows that don't belong
// to the current tenant so we never delete another tenant's data.
- if ($this->sharedTables && ($row['_tenant'] ?? null) !== $this->getTenant()) {
+ if ($this->sharedTables && ($row[Storage::TENANT] ?? null) !== $this->getTenant()) {
continue;
}
- if (isset($seqSet[(string) ($row['_id'] ?? '')])) {
- $deletedIds[(string) ($row['_uid'] ?? $docKey)] = true;
+ $rowId = $row[Storage::SEQUENCE] ?? '';
+ $rowUid = $row[Storage::UID] ?? $docKey;
+ if (isset($seqSet[\is_scalar($rowId) ? (string) $rowId : ''])) {
+ $deletedIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true;
$oldSignatures = $this->rowUniqueSignatures($key, $row);
unset($this->data[$key]['documents'][$docKey]);
$this->journal(function () use ($key, $docKey, $row): void {
@@ -1633,14 +1721,14 @@ public function deleteDocuments(string $collection, array $sequences, array $per
return $count;
}
- public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array
+ public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array
{
$key = $this->key($collection->getId());
if (! isset($this->data[$key])) {
throw new NotFoundException('Collection not found');
}
- $rows = $this->fusedFilter($key, $collection->getId(), $queries, $forPermission);
+ $rows = $this->fusedFilter($key, $collection->getId(), $queries, $forPermission->value);
$rows = $this->applyOrdering($rows, $orderAttributes, $orderTypes, $cursorDirection);
$rows = $this->applyCursor($rows, $orderAttributes, $orderTypes, $cursor, $cursorDirection);
@@ -1657,7 +1745,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
$results[] = new Document($this->rowToDocument($row, $selections, $key));
}
- if ($cursorDirection === Database::CURSOR_BEFORE) {
+ if ($cursorDirection === CursorDirection::Before) {
$results = \array_reverse($results);
}
@@ -1671,7 +1759,7 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
throw new NotFoundException('Collection not found');
}
- $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ);
+ $rows = $this->fusedFilter($key, $collection->getId(), $queries, PermissionType::Read->value);
if (! is_null($max)) {
// MariaDB applies LIMIT :max inside the COUNT subquery — LIMIT 0
@@ -1689,7 +1777,7 @@ public function sum(Document $collection, string $attribute, array $queries = []
throw new NotFoundException('Collection not found');
}
- $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ);
+ $rows = $this->fusedFilter($key, $collection->getId(), $queries, PermissionType::Read->value);
if (! is_null($max)) {
$rows = \array_slice($rows, 0, $max);
@@ -1699,19 +1787,20 @@ public function sum(Document $collection, string $attribute, array $queries = []
$isFloat = false;
$column = $this->filter($attribute);
foreach ($rows as $row) {
- if (! \array_key_exists($column, $row) || $row[$column] === null) {
+ $value = $row[$column] ?? null;
+ if ($value === null || ! \is_numeric($value)) {
continue;
}
- if (\is_float($row[$column])) {
+ if (\is_float($value)) {
$isFloat = true;
}
- $sum += $row[$column];
+ $sum += $value;
}
return $isFloat ? (float) $sum : (int) $sum;
}
- public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, string $updatedAt, int|float|null $min = null, int|float|null $max = null): bool
+ public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool
{
$key = $this->key($collection);
$docKey = $this->documentKey($id);
@@ -1721,9 +1810,10 @@ public function increaseDocumentAttribute(string $collection, string $id, string
$column = $this->filter($attribute);
$previousValue = $this->data[$key]['documents'][$docKey][$column] ?? null;
- $previousUpdatedAt = $this->data[$key]['documents'][$docKey]['_updatedAt'] ?? null;
+ $previousUpdatedAt = $this->data[$key]['documents'][$docKey][Storage::UPDATED_AT] ?? null;
$current = $previousValue ?? 0;
- $current = is_numeric($current) ? $current + 0 : 0;
+ $exact = (\is_int($current) || (\is_string($current) && BigInt::isIntegerString($current)))
+ && (\is_int($value) || (\is_string($value) && BigInt::isIntegerString($value)));
// MariaDB encodes the bound check as part of the WHERE clause against
// the current column value (`attr <= :max` / `attr >= :min`); when the
@@ -1731,27 +1821,47 @@ public function increaseDocumentAttribute(string $collection, string $id, string
// still returns true. Mirror that — silent no-op on bound violation.
// The Database layer pre-subtracts $value from $max (and adds it to
// $min), so the comparison stays against the pre-update value.
- if (! is_null($min) && $current < $min) {
- return true;
- }
- if (! is_null($max) && $current > $max) {
- return true;
+ if ($exact) {
+ $current = BigInt::toNative($current);
+ $value = BigInt::toNative($value);
+ if (! is_null($min) && BigInt::compare($current, $min) < 0) {
+ return true;
+ }
+ if (! is_null($max) && BigInt::compare($current, $max) > 0) {
+ return true;
+ }
+ $result = BigInt::add($current, $value);
+ } else {
+ $current = $this->numericValue($current, 0) ?? 0;
+ $value = $this->numericValue($value, 0) ?? 0;
+ if (! is_null($min) && $current < $min) {
+ return true;
+ }
+ if (! is_null($max) && $current > $max) {
+ return true;
+ }
+ $result = $current + $value;
}
- $this->data[$key]['documents'][$docKey][$column] = $current + $value;
- $this->data[$key]['documents'][$docKey]['_updatedAt'] = $updatedAt;
+ $this->data[$key]['documents'][$docKey][$column] = $result;
+ $this->data[$key]['documents'][$docKey][Storage::UPDATED_AT] = $updatedAt;
$this->journal(function () use ($key, $docKey, $column, $previousValue, $previousUpdatedAt): void {
+ if (! isset($this->data[$key]['documents'][$docKey])) {
+ return;
+ }
+ $row = &$this->data[$key]['documents'][$docKey];
if ($previousValue === null) {
- unset($this->data[$key]['documents'][$docKey][$column]);
+ unset($row[$column]);
} else {
- $this->data[$key]['documents'][$docKey][$column] = $previousValue;
+ $row[$column] = $previousValue;
}
if ($previousUpdatedAt === null) {
- unset($this->data[$key]['documents'][$docKey]['_updatedAt']);
+ unset($row[Storage::UPDATED_AT]);
} else {
- $this->data[$key]['documents'][$docKey]['_updatedAt'] = $previousUpdatedAt;
+ $row[Storage::UPDATED_AT] = $previousUpdatedAt;
}
+ unset($row);
});
return true;
@@ -1817,17 +1927,7 @@ public function getMinDateTime(): \DateTime
public function getIdAttributeType(): string
{
- return Database::VAR_INTEGER;
- }
-
- public function getSupportForSchemas(): bool
- {
- return true;
- }
-
- public function getSupportForAttributes(): bool
- {
- return $this->supportForAttributes;
+ return ColumnType::Integer->value;
}
public function setSupportForAttributes(bool $support): bool
@@ -1837,203 +1937,18 @@ public function setSupportForAttributes(bool $support): bool
return $this->supportForAttributes;
}
- public function getSupportForSchemaAttributes(): bool
- {
- return false;
- }
-
- public function getSupportForSchemaIndexes(): bool
- {
- return false;
- }
-
- public function getSupportForIndex(): bool
- {
- return true;
- }
-
- public function getSupportForIndexArray(): bool
- {
- return false;
- }
-
- public function getSupportForCastIndexArray(): bool
- {
- return false;
- }
-
- public function getSupportForUniqueIndex(): bool
- {
- return true;
- }
-
- public function getSupportForFulltextIndex(): bool
- {
- return true;
- }
-
- public function getSupportForFulltextWildcardIndex(): bool
- {
- return false;
- }
-
- public function getSupportForCasting(): bool
- {
- // Memory stores native PHP types where possible but JSON-encodes array
- // attributes on write. Returning true asks the Database layer's
- // `casting` step to JSON-decode array columns and coerce scalar types
- // — same behaviour as the SQL adapters.
- return true;
- }
-
- public function getSupportForQueryContains(): bool
- {
- return true;
- }
-
- public function getSupportForTimeouts(): bool
- {
- return false;
- }
-
- public function getSupportForRelationships(): bool
- {
- return true;
- }
-
- public function getSupportForUpdateLock(): bool
- {
- return false;
- }
-
- public function getSupportForBatchOperations(): bool
- {
- return true;
- }
-
- public function getSupportForAttributeResizing(): bool
- {
- return true;
- }
-
- public function getSupportForGetConnectionId(): bool
- {
- return false;
- }
-
- public function getSupportForUpserts(): bool
- {
- return false;
- }
-
- public function getSupportForUpsertOnUniqueIndex(): bool
- {
- return false;
- }
-
- public function getSupportForVectors(): bool
- {
- return false;
- }
-
- public function getSupportForCacheSkipOnFailure(): bool
- {
- return false;
- }
-
- public function getSupportForCaching(): bool
- {
- return true;
- }
-
- public function getSupportForReconnection(): bool
- {
- return false;
- }
-
- public function getSupportForHostname(): bool
- {
- return false;
- }
-
- public function getSupportForBatchCreateAttributes(): bool
- {
- return true;
- }
-
- public function getSupportForSpatialAttributes(): bool
- {
- return false;
- }
-
- public function getSupportForObject(): bool
- {
- return true;
- }
-
- public function getSupportForObjectIndexes(): bool
- {
- return true;
- }
-
- public function getSupportForSpatialIndexNull(): bool
- {
- return false;
- }
-
- public function getSupportForOperators(): bool
- {
- return true;
- }
-
- public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool
- {
- return false;
- }
-
- public function getSupportForSpatialIndexOrder(): bool
- {
- return false;
- }
-
- public function getSupportForSpatialAxisOrder(): bool
- {
- return false;
- }
-
- public function getSupportForBoundaryInclusiveContains(): bool
- {
- return false;
- }
-
- public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool
- {
- return false;
- }
-
- public function getSupportForMultipleFulltextIndexes(): bool
- {
- return false;
- }
-
- public function getSupportForIdenticalIndexes(): bool
- {
- return false;
- }
-
- public function getSupportForOrderRandom(): bool
- {
- return true;
- }
-
public function getCountOfAttributes(Document $collection): int
{
- return \count($collection->getAttribute('attributes', [])) + $this->getCountOfDefaultAttributes();
+ $attributes = $collection->getAttribute('attributes', []);
+
+ return (\is_array($attributes) ? \count($attributes) : 0) + $this->getCountOfDefaultAttributes();
}
public function getCountOfIndexes(Document $collection): int
{
- return \count($collection->getAttribute('indexes', [])) + $this->getCountOfDefaultIndexes();
+ $indexes = $collection->getAttribute('indexes', []);
+
+ return (\is_array($indexes) ? \count($indexes) : 0) + $this->getCountOfDefaultIndexes();
}
public function getCountOfDefaultAttributes(): int
@@ -2066,26 +1981,11 @@ protected function getAttributeProjection(array $selections, string $prefix): mi
return $selections;
}
- public function getConnectionId(): string
- {
- return '0';
- }
-
public function getInternalIndexesKeys(): array
{
return [];
}
- public function getSchemaAttributes(string $collection): array
- {
- return [];
- }
-
- public function getSchemaIndexes(string $collection): array
- {
- return [];
- }
-
public function getTenantQuery(string $collection, string $alias = ''): string
{
return '';
@@ -2101,31 +2001,6 @@ protected function quote(string $string): string
return '"'.$string.'"';
}
- public function decodePoint(string $wkb): array
- {
- throw new DatabaseException('Spatial types are not implemented in the Memory adapter');
- }
-
- public function decodeLinestring(string $wkb): array
- {
- throw new DatabaseException('Spatial types are not implemented in the Memory adapter');
- }
-
- public function decodePolygon(string $wkb): array
- {
- throw new DatabaseException('Spatial types are not implemented in the Memory adapter');
- }
-
- public function castingBefore(Document $collection, Document $document): Document
- {
- return $document;
- }
-
- public function castingAfter(Document $collection, Document $document): Document
- {
- return $document;
- }
-
/**
* Get max BIGINT limit
*
@@ -2136,31 +2011,6 @@ public function getLimitForBigInt(): int
return Database::MAX_BIG_INT;
}
- public function getSupportForInternalCasting(): bool
- {
- return false;
- }
-
- public function getSupportForUTCCasting(): bool
- {
- return false;
- }
-
- public function setUTCDatetime(string $value): mixed
- {
- return $value;
- }
-
- public function getSupportForIntegerBooleans(): bool
- {
- return false;
- }
-
- public function getSupportForAlterLocks(): bool
- {
- return false;
- }
-
public function getSupportNonUtfCharacters(): bool
{
// Memory is a pass-through PHP array, so it does NOT actively reject
@@ -2169,35 +2019,6 @@ public function getSupportNonUtfCharacters(): bool
return false;
}
- public function getSupportForTrigramIndex(): bool
- {
- return false;
- }
-
- public function getSupportForPCRERegex(): bool
- {
- return true;
- }
-
- public function getSupportForPOSIXRegex(): bool
- {
- return false;
- }
-
- public function getSupportForTransactionRetries(): bool
- {
- return false;
- }
-
- public function getSupportForNestedTransactions(): bool
- {
- return true;
- }
-
- // -----------------------------------------------------------------
- // Internal helpers
- // -----------------------------------------------------------------
-
/**
* @return array
*/
@@ -2211,15 +2032,15 @@ protected function documentToRow(Document $document): array
$row[$this->filter($attribute)] = $value;
}
- $row['_uid'] = $document->getId();
- $row['_createdAt'] = $document->getCreatedAt();
- $row['_updatedAt'] = $document->getUpdatedAt();
- $row['_permissions'] = $document->getPermissions();
+ $row[Storage::UID] = $document->getId();
+ $row[Storage::CREATED_AT] = $document->getCreatedAt();
+ $row[Storage::UPDATED_AT] = $document->getUpdatedAt();
+ $row[Storage::PERMISSIONS] = $document->getPermissions();
if ($this->sharedTables) {
// Mirror MariaDB: the row's `_tenant` follows the document's own
// tenant — that matters in tenantPerDocument mode where the
// adapter's current tenant is null but each document is tagged.
- $row['_tenant'] = $document->getTenant() ?? $this->getTenant();
+ $row[Storage::TENANT] = $document->getTenant() ?? $this->getTenant();
}
return $row;
@@ -2251,23 +2072,23 @@ protected function rowToDocument(array $row, ?array $selections = null, ?string
$document = [];
foreach ($row as $key => $value) {
switch ($key) {
- case '_id':
- $document['$sequence'] = (string) $value;
+ case Storage::SEQUENCE:
+ $document[Document::SEQUENCE] = \is_scalar($value) ? (string) $value : '';
break;
- case '_uid':
- $document['$id'] = $value;
+ case Storage::UID:
+ $document[Document::ID] = $value;
break;
- case '_tenant':
- $document['$tenant'] = $value;
+ case Storage::TENANT:
+ $document[Document::TENANT] = $value;
break;
- case '_createdAt':
- $document['$createdAt'] = $value;
+ case Storage::CREATED_AT:
+ $document[Document::CREATED_AT] = $value;
break;
- case '_updatedAt':
- $document['$updatedAt'] = $value;
+ case Storage::UPDATED_AT:
+ $document[Document::UPDATED_AT] = $value;
break;
- case '_permissions':
- $document['$permissions'] = $value ?? [];
+ case Storage::PERMISSIONS:
+ $document[Document::PERMISSIONS] = $value ?? [];
break;
default:
if ($allowed !== null && ! isset($allowed[$key])) {
@@ -2281,7 +2102,7 @@ protected function rowToDocument(array $row, ?array $selections = null, ?string
// MariaDB selecting a `DEFAULT NULL` column even when no row has set it.
if ($storageKey !== null && isset($this->data[$storageKey]['attributes'])) {
foreach ($this->data[$storageKey]['attributes'] as $attributeId => $definition) {
- if (($definition['type'] ?? null) !== Database::VAR_RELATIONSHIP) {
+ if (($definition['type'] ?? null) !== ColumnType::Relationship->value) {
continue;
}
if ($allowed !== null && ! isset($allowed[$attributeId])) {
@@ -2304,7 +2125,7 @@ protected function extractSelections(array $queries): array
{
$selections = [];
foreach ($queries as $query) {
- if ($query->getMethod() === Query::TYPE_SELECT) {
+ if ($query->getMethod() === Method::Select) {
foreach ($query->getValues() as $value) {
if (\is_string($value)) {
$selections[] = $value;
@@ -2320,9 +2141,9 @@ protected function writePermissions(string $key, Document $document): void
{
$uid = $document->getId();
$tenant = $document->getTenant() ?? $this->getTenant();
- foreach (Database::PERMISSIONS as $type) {
+ foreach ([PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete] as $type) {
foreach ($document->getPermissionsByType($type) as $permission) {
- $this->addPermissionEntry($key, $uid, $type, $permission, $tenant);
+ $this->addPermissionEntry($key, $uid, $type->value, (string) $permission, $tenant);
}
}
}
@@ -2480,15 +2301,18 @@ protected function rowUniqueSignatures(string $key, array $row): array
{
$result = [];
foreach ($this->data[$key]['indexes'] ?? [] as $indexId => $index) {
- if (($index['type'] ?? '') !== Database::INDEX_UNIQUE) {
+ if (($index['type'] ?? '') !== IndexType::Unique->value) {
continue;
}
$attributes = $index['attributes'] ?? [];
- if (empty($attributes)) {
+ if (! \is_array($attributes) || empty($attributes)) {
continue;
}
$signature = [];
foreach ($attributes as $attribute) {
+ if (! \is_string($attribute)) {
+ continue;
+ }
$signature[] = $this->normalizeIndexValue($this->resolveAttributeValue($row, $attribute));
}
if (\in_array(null, $signature, true)) {
@@ -2499,7 +2323,7 @@ protected function rowUniqueSignatures(string $key, array $row): array
// tenant into the hash key so two tenants holding the same
// value do not collide.
if ($this->sharedTables) {
- \array_unshift($signature, $row['_tenant'] ?? null);
+ \array_unshift($signature, $row[Storage::TENANT] ?? null);
}
$result[$indexId] = \serialize($signature);
}
@@ -2516,24 +2340,30 @@ protected function documentUniqueSignatures(string $key, Document $document): ar
{
$result = [];
foreach ($this->data[$key]['indexes'] ?? [] as $indexId => $index) {
- if (($index['type'] ?? '') !== Database::INDEX_UNIQUE) {
+ if (($index['type'] ?? '') !== IndexType::Unique->value) {
continue;
}
$attributes = $index['attributes'] ?? [];
- if (empty($attributes)) {
+ if (! \is_array($attributes) || empty($attributes)) {
continue;
}
$signature = [];
foreach ($attributes as $attribute) {
+ if (! \is_string($attribute)) {
+ continue;
+ }
$signature[] = $this->normalizeIndexValue($this->resolveDocumentValue($document, $attribute));
}
if (\in_array(null, $signature, true)) {
continue;
}
// Match rowUniqueSignatures: under shared tables, scope by the
- // current adapter tenant so cross-tenant collisions never throw.
+ // tenant the row will actually be stored under. documentToRow
+ // writes `_tenant = $document->getTenant() ?? $this->getTenant()`,
+ // so the read- and write-side signatures must agree on that
+ // fallback or duplicate detection skips across tenants.
if ($this->sharedTables) {
- \array_unshift($signature, $this->getTenant());
+ \array_unshift($signature, $document->getTenant() ?? $this->getTenant());
}
$result[$indexId] = \serialize($signature);
}
@@ -2561,7 +2391,7 @@ protected function fusedFilter(string $key, string $collectionId, array $queries
$effectiveQueries = [];
foreach ($queries as $query) {
$method = $query->getMethod();
- if (\in_array($method, [Query::TYPE_SELECT, Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC, Query::TYPE_ORDER_RANDOM, Query::TYPE_LIMIT, Query::TYPE_OFFSET, Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) {
+ if (\in_array($method, [Method::Select, Method::OrderAsc, Method::OrderDesc, Method::OrderRandom, Method::Limit, Method::Offset, Method::CursorAfter, Method::CursorBefore], true)) {
continue;
}
$effectiveQueries[] = $query;
@@ -2576,7 +2406,7 @@ protected function fusedFilter(string $key, string $collectionId, array $queries
$output = [];
foreach ($documents as $row) {
if ($tenantCheck) {
- $rowTenant = $row['_tenant'] ?? null;
+ $rowTenant = $row[Storage::TENANT] ?? null;
if ($allowNullTenant && $rowTenant === null) {
// visible
} elseif ($rowTenant !== $tenant) {
@@ -2584,7 +2414,8 @@ protected function fusedFilter(string $key, string $collectionId, array $queries
}
}
- if ($allowSet !== null && ! isset($allowSet[$row['_uid'] ?? ''])) {
+ $rowUid = $row[Storage::UID] ?? '';
+ if ($allowSet !== null && (! \is_string($rowUid) || ! isset($allowSet[$rowUid]))) {
continue;
}
@@ -2612,7 +2443,7 @@ protected function matches(array $row, Query $query): bool
{
$method = $query->getMethod();
- if ($method === Query::TYPE_AND) {
+ if ($method === Method::And) {
foreach ($query->getValues() as $sub) {
if (! ($sub instanceof Query) || ! $this->matches($row, $sub)) {
return false;
@@ -2622,7 +2453,7 @@ protected function matches(array $row, Query $query): bool
return true;
}
- if ($method === Query::TYPE_OR) {
+ if ($method === Method::Or) {
foreach ($query->getValues() as $sub) {
if ($sub instanceof Query && $this->matches($row, $sub)) {
return true;
@@ -2642,8 +2473,17 @@ protected function matches(array $row, Query $query): bool
}
switch ($method) {
- case Query::TYPE_EQUAL:
+ case Method::Equal:
+ // SQL three-valued logic: `col = NULL` is unknown — null rows
+ // never match an explicit equality, even when callers pass
+ // `[null]`. Use `Query::isNull()` for that case.
+ if ($value === null) {
+ return false;
+ }
foreach ($queryValues as $candidate) {
+ if ($candidate === null) {
+ continue;
+ }
if ($this->looseEquals($value, $candidate)) {
return true;
}
@@ -2651,12 +2491,19 @@ protected function matches(array $row, Query $query): bool
return false;
- case Query::TYPE_NOT_EQUAL:
+ case Method::NotEqual:
// SQL: NULL != x evaluates to NULL (i.e. excluded), not true.
if ($value === null) {
return false;
}
foreach ($queryValues as $candidate) {
+ // SQL three-valued logic: `col NOT IN (..., NULL, ...)`
+ // is unknown for every row — exclude. Mirrors the null-
+ // candidate handling in Method::Equal above. Use
+ // `Query::isNotNull()` for the explicit not-null intent.
+ if ($candidate === null) {
+ return false;
+ }
if ($this->looseEquals($value, $candidate)) {
return false;
}
@@ -2664,28 +2511,28 @@ protected function matches(array $row, Query $query): bool
return true;
- case Query::TYPE_LESSER:
+ case Method::LessThan:
return $value !== null && $value < $queryValues[0];
- case Query::TYPE_LESSER_EQUAL:
+ case Method::LessThanEqual:
return $value !== null && $value <= $queryValues[0];
- case Query::TYPE_GREATER:
+ case Method::GreaterThan:
return $value !== null && $value > $queryValues[0];
- case Query::TYPE_GREATER_EQUAL:
+ case Method::GreaterThanEqual:
return $value !== null && $value >= $queryValues[0];
- case Query::TYPE_IS_NULL:
+ case Method::IsNull:
return $value === null;
- case Query::TYPE_IS_NOT_NULL:
+ case Method::IsNotNull:
return $value !== null;
- case Query::TYPE_BETWEEN:
+ case Method::Between:
return $value !== null && $value >= $queryValues[0] && $value <= $queryValues[1];
- case Query::TYPE_NOT_BETWEEN:
+ case Method::NotBetween:
// SQL: NULL NOT BETWEEN x AND y evaluates to NULL (excluded).
if ($value === null) {
return false;
@@ -2693,27 +2540,27 @@ protected function matches(array $row, Query $query): bool
return $value < $queryValues[0] || $value > $queryValues[1];
- case Query::TYPE_STARTS_WITH:
+ case Method::StartsWith:
return \is_string($value) && \is_string($queryValues[0]) && \str_starts_with($value, $queryValues[0]);
- case Query::TYPE_NOT_STARTS_WITH:
+ case Method::NotStartsWith:
if ($value === null) {
return false;
}
return ! \is_string($value) || ! \is_string($queryValues[0]) || ! \str_starts_with($value, $queryValues[0]);
- case Query::TYPE_ENDS_WITH:
+ case Method::EndsWith:
return \is_string($value) && \is_string($queryValues[0]) && \str_ends_with($value, $queryValues[0]);
- case Query::TYPE_NOT_ENDS_WITH:
+ case Method::NotEndsWith:
if ($value === null) {
return false;
}
return ! \is_string($value) || ! \is_string($queryValues[0]) || ! \str_ends_with($value, $queryValues[0]);
- case Query::TYPE_CONTAINS:
+ case Method::Contains:
$haystack = $this->decodeArrayValue($value);
if ($haystack === null && \is_string($value)) {
// Mirror MariaDB's default case-insensitive collation for
@@ -2740,16 +2587,16 @@ protected function matches(array $row, Query $query): bool
return false;
- case Query::TYPE_NOT_CONTAINS:
+ case Method::NotContains:
// SQL: NULL NOT LIKE '%x%' / JSON_CONTAINS(NULL, ...) evaluates
// to NULL — null-valued rows are excluded, not matched.
if ($value === null) {
return false;
}
- return ! $this->matches($row, new Query(Query::TYPE_CONTAINS, $query->getAttribute(), $queryValues));
+ return ! $this->matches($row, new Query(Method::Contains, $query->getAttribute(), $queryValues));
- case Query::TYPE_CONTAINS_ANY:
+ case Method::ContainsAny:
// containsAny behaves like contains: array attributes match
// any of the supplied needles, scalar string attributes fall
// back to a case-insensitive substring search.
@@ -2776,7 +2623,7 @@ protected function matches(array $row, Query $query): bool
return false;
- case Query::TYPE_CONTAINS_ALL:
+ case Method::ContainsAll:
$haystack = $this->decodeArrayValue($value);
if (! \is_array($haystack)) {
return false;
@@ -2796,18 +2643,18 @@ protected function matches(array $row, Query $query): bool
return true;
- case Query::TYPE_SEARCH:
+ case Method::Search:
if (! \is_string($value)) {
return false;
}
- $needle = (string) ($queryValues[0] ?? '');
- if ($needle === '') {
+ $searchNeedle = $queryValues[0] ?? '';
+ if (! \is_string($searchNeedle) || $searchNeedle === '') {
return false;
}
- return $this->matchesFulltext($value, $needle);
+ return $this->matchesFulltext($value, $searchNeedle);
- case Query::TYPE_NOT_SEARCH:
+ case Method::NotSearch:
// SQL: NULL NOT MATCH evaluates to NULL — null rows excluded.
if ($value === null) {
return false;
@@ -2815,23 +2662,26 @@ protected function matches(array $row, Query $query): bool
if (! \is_string($value)) {
return true;
}
- $needle = (string) ($queryValues[0] ?? '');
- if ($needle === '') {
+ $notSearchNeedle = $queryValues[0] ?? '';
+ if (! \is_string($notSearchNeedle) || $notSearchNeedle === '') {
return true;
}
- return ! $this->matchesFulltext($value, $needle);
+ return ! $this->matchesFulltext($value, $notSearchNeedle);
- case Query::TYPE_REGEX:
+ case Method::Regex:
if (! \is_string($value)) {
return false;
}
- $pattern = (string) ($queryValues[0] ?? '');
+ $pattern = $queryValues[0] ?? '';
+ if (! \is_string($pattern)) {
+ return false;
+ }
return $this->matchesRegex($value, $pattern);
}
- throw new DatabaseException('Query method not implemented in the Memory adapter: '.$method);
+ throw new DatabaseException('Query method not implemented in the Memory adapter: '.$method->value);
}
/**
@@ -2913,7 +2763,11 @@ protected function looseEquals(mixed $a, mixed $b): bool
return true;
}
if (\is_numeric($a) && \is_numeric($b)) {
- return $a + 0 === $b + 0;
+ // Compare numerically with `==` so cross-type pairs like
+ // ("3", "3.0") or (3, 3.0) match the way SQL `WHERE col = '3.0'`
+ // matches an int column holding 3. Strict `===` after `+0`
+ // splits int/float and silently misses parity.
+ return $a == $b;
}
return false;
@@ -2952,7 +2806,7 @@ protected function matchesObject(mixed $value, Query $query): bool
$method = $query->getMethod();
switch ($method) {
- case Query::TYPE_EQUAL:
+ case Method::Equal:
if ($haystack === null) {
return false;
}
@@ -2964,7 +2818,7 @@ protected function matchesObject(mixed $value, Query $query): bool
return false;
- case Query::TYPE_NOT_EQUAL:
+ case Method::NotEqual:
// Postgres: NOT (NULL @> x) evaluates to NULL — null/invalid
// JSON rows are excluded, mirroring SQL three-valued logic.
if ($haystack === null) {
@@ -2978,8 +2832,8 @@ protected function matchesObject(mixed $value, Query $query): bool
return true;
- case Query::TYPE_CONTAINS:
- case Query::TYPE_CONTAINS_ANY:
+ case Method::Contains:
+ case Method::ContainsAny:
if ($haystack === null) {
return false;
}
@@ -2991,7 +2845,7 @@ protected function matchesObject(mixed $value, Query $query): bool
return false;
- case Query::TYPE_CONTAINS_ALL:
+ case Method::ContainsAll:
if ($haystack === null) {
return false;
}
@@ -3003,7 +2857,7 @@ protected function matchesObject(mixed $value, Query $query): bool
return true;
- case Query::TYPE_NOT_CONTAINS:
+ case Method::NotContains:
// Postgres three-valued logic: NULL field excluded from negation.
if ($haystack === null) {
return false;
@@ -3016,32 +2870,36 @@ protected function matchesObject(mixed $value, Query $query): bool
return true;
- case Query::TYPE_IS_NULL:
+ case Method::IsNull:
return $value === null;
- case Query::TYPE_IS_NOT_NULL:
+ case Method::IsNotNull:
return $value !== null;
}
- throw new DatabaseException('Query method '.$method.' not supported for object attributes');
+ throw new DatabaseException('Query method '.$method->value.' not supported for object attributes');
}
- protected function decodeObjectValue(mixed $value): mixed
+ /**
+ * Return the decoded array if $value is already an array or looks like a
+ * JSON object/array literal; null otherwise. Mirrors decodeArrayValue and
+ * lets matchesObject's callers rely on a single `null === no match` guard
+ * rather than dispatching on raw scalar types.
+ *
+ * @return array|null
+ */
+ protected function decodeObjectValue(mixed $value): ?array
{
- if ($value === null) {
- return null;
- }
if (\is_array($value)) {
return $value;
}
if (\is_string($value) && $value !== '' && ($value[0] === '{' || $value[0] === '[')) {
$decoded = \json_decode($value, true);
- if (\is_array($decoded)) {
- return $decoded;
- }
+
+ return \is_array($decoded) ? $decoded : null;
}
- return $value;
+ return null;
}
/**
@@ -3201,15 +3059,7 @@ protected function resolveNestedPath(mixed $value, string $path): mixed
protected function mapAttribute(string $attribute): string
{
- return match ($attribute) {
- '$id' => '_uid',
- '$sequence' => '_id',
- '$tenant' => '_tenant',
- '$createdAt' => '_createdAt',
- '$updatedAt' => '_updatedAt',
- '$permissions' => '_permissions',
- default => $this->filter($attribute),
- };
+ return $this->filter(Storage::column($attribute));
}
/**
@@ -3259,30 +3109,30 @@ protected function buildPermissionAllowSet(string $key, string $forPermission):
/**
* @param array> $rows
* @param array $orderAttributes
- * @param array $orderTypes
+ * @param array $orderTypes
* @return array>
*/
- protected function applyOrdering(array $rows, array $orderAttributes, array $orderTypes, string $cursorDirection): array
+ protected function applyOrdering(array $rows, array $orderAttributes, array $orderTypes, CursorDirection $cursorDirection): array
{
// Random ordering must short-circuit: a non-deterministic comparator
// breaks usort's transitivity invariant. Shuffle once and return.
foreach ($orderTypes as $type) {
- if ($type === Database::ORDER_RANDOM) {
+ if ($type === OrderDirection::Random) {
\shuffle($rows);
return $rows;
}
}
- $reverse = $cursorDirection === Database::CURSOR_BEFORE;
+ $reverse = $cursorDirection === CursorDirection::Before;
if (empty($orderAttributes)) {
// Mirror MariaDB's clustered-index ordering when no explicit ORDER BY
// is supplied — sort by the auto-incrementing _id ascending so
// pagination via limit/offset is stable across calls.
\usort($rows, function (array $a, array $b) use ($reverse) {
- $av = $a['_id'] ?? 0;
- $bv = $b['_id'] ?? 0;
+ $av = $a[Storage::SEQUENCE] ?? 0;
+ $bv = $b[Storage::SEQUENCE] ?? 0;
if ($av === $bv) {
return 0;
}
@@ -3302,11 +3152,11 @@ protected function applyOrdering(array $rows, array $orderAttributes, array $ord
$directions = [];
foreach ($orderAttributes as $i => $attribute) {
$columns[$i] = $this->mapAttribute($attribute);
- $direction = $orderTypes[$i] ?? Database::ORDER_ASC;
+ $direction = $orderTypes[$i] ?? OrderDirection::Asc;
if ($reverse) {
- $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC;
+ $direction = $direction === OrderDirection::Asc ? OrderDirection::Desc : OrderDirection::Asc;
}
- $directions[$i] = $direction === Database::ORDER_ASC ? 1 : -1;
+ $directions[$i] = $direction === OrderDirection::Asc ? 1 : -1;
}
$count = \count($rows);
@@ -3350,31 +3200,31 @@ protected function applyOrdering(array $rows, array $orderAttributes, array $ord
/**
* @param array> $rows
* @param array $orderAttributes
- * @param array $orderTypes
+ * @param array $orderTypes
* @param array $cursor
* @return array>
*/
- protected function applyCursor(array $rows, array $orderAttributes, array $orderTypes, array $cursor, string $cursorDirection): array
+ protected function applyCursor(array $rows, array $orderAttributes, array $orderTypes, array $cursor, CursorDirection $cursorDirection): array
{
if (empty($cursor)) {
return $rows;
}
if (empty($orderAttributes)) {
- $orderAttributes = ['$sequence'];
- $orderTypes = [Database::ORDER_ASC];
+ $orderAttributes = [Document::SEQUENCE];
+ $orderTypes = [OrderDirection::Asc];
}
- $reverse = $cursorDirection === Database::CURSOR_BEFORE;
+ $reverse = $cursorDirection === CursorDirection::Before;
$resolved = [];
foreach ($orderAttributes as $i => $attribute) {
- $direction = $orderTypes[$i] ?? Database::ORDER_ASC;
+ $direction = $orderTypes[$i] ?? OrderDirection::Asc;
if ($reverse) {
- $direction = $direction === Database::ORDER_ASC ? Database::ORDER_DESC : Database::ORDER_ASC;
+ $direction = $direction === OrderDirection::Asc ? OrderDirection::Desc : OrderDirection::Asc;
}
$resolved[] = [
'column' => $this->mapAttribute($attribute),
- 'asc' => $direction === Database::ORDER_ASC,
+ 'asc' => $direction === OrderDirection::Asc,
'ref' => $cursor[$attribute] ?? null,
];
}
@@ -3467,129 +3317,115 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
{
$values = $operator->getValues();
$method = $operator->getMethod();
+ $exact = BigInt::calculateOutsideNative($method, $current ?? 0, $values[0] ?? 1);
+ if ($exact !== null) {
+ $bound = $values[1] ?? null;
+ if ($method === OperatorType::Modulo || ! BigInt::isIntegerValue($bound)) {
+ return $exact;
+ }
+
+ return $this->applyNumericLimit(
+ $current ?? 0,
+ $exact,
+ $bound,
+ \in_array($method, [OperatorType::Increment, OperatorType::Multiply, OperatorType::Power], true)
+ );
+ }
switch ($method) {
- case Operator::TYPE_INCREMENT:
- $by = $values[0] ?? 1;
- $max = $values[1] ?? null;
- $base = \is_numeric($current) ? $current + 0 : 0;
- if ($max !== null) {
- // Compare *remaining headroom* against $by so we never overflow PHP's int
- // range. Guard: if the RESULT would exceed the max, leave it unchanged.
- // Note: we must NOT short-circuit on `$base >= $max` — a negative $by moves
- // the value down, so an already-over-max base can still land within bound
- // (e.g. 52 + (-5) = 47 <= 50 must apply).
- if (($max - $base) < $by) {
- return $this->preserveNumericType($base, $base);
- }
- }
+ case OperatorType::Increment:
+ $byInc = $this->numericValue($values[0] ?? null, 1);
+ $maxInc = $this->numericValue($values[1] ?? null, null);
+ $baseInc = \is_numeric($current) ? $current + 0 : 0;
- return $this->preserveNumericType($base, $base + $by);
+ return $this->applyNumericLimit($baseInc, $baseInc + $byInc, $maxInc, true);
- case Operator::TYPE_DECREMENT:
- $by = $values[0] ?? 1;
- $min = $values[1] ?? null;
- $base = \is_numeric($current) ? $current + 0 : 0;
- if ($min !== null) {
- // Guard: leave unchanged only if the RESULT would go below min. Don't
- // short-circuit on `$base <= $min` — a negative $by moves the value up.
- if (($base - $min) < $by) {
- return $this->preserveNumericType($base, $base);
- }
- }
+ case OperatorType::Decrement:
+ $byDec = $this->numericValue($values[0] ?? null, 1);
+ $minDec = $this->numericValue($values[1] ?? null, null);
+ $baseDec = \is_numeric($current) ? $current + 0 : 0;
- return $this->preserveNumericType($base, $base - $by);
+ return $this->applyNumericLimit($baseDec, $baseDec - $byDec, $minDec, false);
- case Operator::TYPE_MULTIPLY:
- $by = $values[0] ?? 1;
- $max = $values[1] ?? null;
- $base = \is_numeric($current) ? $current + 0 : 0;
- $result = $base * $by;
- if ($max !== null && $result > $max) {
- return $this->preserveNumericType($base, $base);
- }
+ case OperatorType::Multiply:
+ $byMul = $this->numericValue($values[0] ?? null, 1);
+ $maxMul = $this->numericValue($values[1] ?? null, null);
+ $baseMul = \is_numeric($current) ? $current + 0 : 0;
- return $this->preserveNumericType($base, $result);
+ return $this->applyNumericLimit($baseMul, $baseMul * $byMul, $maxMul, true);
- case Operator::TYPE_DIVIDE:
- $by = $values[0] ?? 1;
- $min = $values[1] ?? null;
- if ($by == 0) {
+ case OperatorType::Divide:
+ $byDiv = $this->numericValue($values[0] ?? null, 1);
+ $minDiv = $this->numericValue($values[1] ?? null, null);
+ if ($byDiv == 0) {
return $current;
}
- $base = \is_numeric($current) ? $current + 0 : 0;
- $result = $base / $by;
- if ($min !== null && $result < $min) {
- return $this->preserveNumericType($base, $base);
- }
+ $baseDiv = \is_numeric($current) ? $current + 0 : 0;
- return $this->preserveNumericType($base, $result);
+ return $this->applyNumericLimit($baseDiv, $baseDiv / $byDiv, $minDiv, false);
- case Operator::TYPE_MODULO:
- $by = $values[0] ?? 1;
- if ($by == 0) {
+ case OperatorType::Modulo:
+ $byMod = (int) $this->numericValue($values[0] ?? null, 1);
+ if ($byMod == 0) {
return $current;
}
- $base = \is_numeric($current) ? (int) $current : 0;
+ $baseMod = \is_numeric($current) ? (int) $current : 0;
- return $base % (int) $by;
+ return $baseMod % $byMod;
- case Operator::TYPE_POWER:
- $by = $values[0] ?? 1;
- $max = $values[1] ?? null;
- $base = \is_numeric($current) ? $current + 0 : 0;
- if ($max !== null) {
- // Leave the value unchanged for undefined inputs (0 to a negative power, or a
- // negative base to a fractional exponent) — they produce INF/NaN, not a number.
- if (($base == 0 && $by < 0) || ($base < 0 && \floor($by) != $by)) {
- return $this->preserveNumericType($base, $base);
- }
- $result = $base ** $by;
- // A result that overflows (INF) or exceeds the max also leaves the value as-is.
- if (!\is_finite($result) || $result > $max) {
- return $this->preserveNumericType($base, $base);
+ case OperatorType::Power:
+ $byPow = $this->numericValue($values[0] ?? null, 1) ?? 1;
+ $maxPow = $this->numericValue($values[1] ?? null, null);
+ $basePow = \is_numeric($current) ? $current + 0 : 0;
+ if (($basePow == 0 && $byPow < 0) || ($basePow < 0 && \floor($byPow) != $byPow)) {
+ if ($maxPow !== null) {
+ return $basePow;
}
- return $this->preserveNumericType($base, $result);
+ throw new LimitException('Value out of range');
}
- // 0 to a negative power, or a negative base to a fractional exponent, is not a real
- // number. Fail loudly with a clear exception rather than storing INF/NaN.
- $result = $base ** $by;
- if (!\is_finite($result)) {
+ $candidate = $basePow ** $byPow;
+ if (! \is_finite((float) $candidate)) {
+ if ($maxPow !== null) {
+ return $basePow;
+ }
+
throw new LimitException('Value out of range');
}
- return $this->preserveNumericType($base, $result);
+ return $this->applyNumericLimit($basePow, $candidate, $maxPow, true);
- case Operator::TYPE_STRING_CONCAT:
- return ((string) ($current ?? '')).(string) ($values[0] ?? '');
+ case OperatorType::StringConcat:
+ $appendValue = $values[0] ?? '';
- case Operator::TYPE_STRING_REPLACE:
- $search = (string) ($values[0] ?? '');
- $replace = (string) ($values[1] ?? '');
+ return $this->stringValue($current).$this->stringValue($appendValue);
+
+ case OperatorType::StringReplace:
+ $search = $this->stringValue($values[0] ?? '');
+ $replace = $this->stringValue($values[1] ?? '');
if ($current === null) {
return null;
}
- return \str_replace($search, $replace, (string) $current);
+ return \str_replace($search, $replace, $this->stringValue($current));
- case Operator::TYPE_TOGGLE:
+ case OperatorType::Toggle:
return ! (bool) $current;
- case Operator::TYPE_ARRAY_APPEND:
+ case OperatorType::ArrayAppend:
$list = $this->coerceArray($current);
return [...$list, ...\array_values($values)];
- case Operator::TYPE_ARRAY_PREPEND:
+ case OperatorType::ArrayPrepend:
$list = $this->coerceArray($current);
return [...\array_values($values), ...$list];
- case Operator::TYPE_ARRAY_INSERT:
+ case OperatorType::ArrayInsert:
$list = $this->coerceArray($current);
- $index = (int) ($values[0] ?? 0);
+ $index = (int) $this->numericValue($values[0] ?? null, 0);
$value = $values[1] ?? null;
if ($index < 0) {
$index = 0;
@@ -3601,65 +3437,109 @@ protected function applyOperator(mixed $current, Operator $operator): mixed
return $list;
- case Operator::TYPE_ARRAY_REMOVE:
+ case OperatorType::ArrayRemove:
$list = $this->coerceArray($current);
$needle = $values[0] ?? null;
return \array_values(\array_filter($list, fn ($item) => $item !== $needle));
- case Operator::TYPE_ARRAY_UNIQUE:
+ case OperatorType::ArrayUnique:
$list = $this->coerceArray($current);
return \array_values(\array_unique($list, SORT_REGULAR));
- case Operator::TYPE_ARRAY_INTERSECT:
+ case OperatorType::ArrayIntersect:
$list = $this->coerceArray($current);
$other = \array_values($values);
return \array_values(\array_filter($list, fn ($item) => \in_array($item, $other, false)));
- case Operator::TYPE_ARRAY_DIFF:
+ case OperatorType::ArrayDiff:
$list = $this->coerceArray($current);
$other = \array_values($values);
return \array_values(\array_filter($list, fn ($item) => ! \in_array($item, $other, false)));
- case Operator::TYPE_ARRAY_FILTER:
+ case OperatorType::ArrayFilter:
$list = $this->coerceArray($current);
- $condition = (string) ($values[0] ?? '');
+ $condition = $this->stringValue($values[0] ?? '');
$compare = $values[1] ?? null;
return \array_values(\array_filter($list, fn ($item) => $this->matchesArrayFilter($item, $condition, $compare)));
- case Operator::TYPE_DATE_ADD_DAYS:
- $days = (int) ($values[0] ?? 0);
+ case OperatorType::DateAddDays:
+ $days = (int) $this->numericValue($values[0] ?? null, 0);
return $this->shiftDate($current, $days * 86400);
- case Operator::TYPE_DATE_SUB_DAYS:
- $days = (int) ($values[0] ?? 0);
+ case OperatorType::DateSubDays:
+ $days = (int) $this->numericValue($values[0] ?? null, 0);
return $this->shiftDate($current, -$days * 86400);
- case Operator::TYPE_DATE_SET_NOW:
+ case OperatorType::DateSetNow:
return DateTime::now();
}
+ }
+
+ /**
+ * Coerce a mixed value to int|float, falling back to $default when the
+ * value is not numeric. Centralises the narrow-to-numeric pattern used
+ * across the operator implementations.
+ */
+ protected function numericValue(mixed $value, int|float|null $default): int|float|null
+ {
+ if (\is_int($value) || \is_float($value)) {
+ return $value;
+ }
+ if (\is_string($value) && \is_numeric($value)) {
+ return $value + 0;
+ }
- throw new OperatorException("Invalid operator: {$method}");
+ return $default;
}
/**
- * Clamp an arithmetic result against an optional bound.
+ * Coerce a mixed value to string, falling back to '' when the value is
+ * not stringable. Centralises the narrow-to-string pattern used across
+ * the string-operator implementations.
+ */
+ protected function stringValue(mixed $value): string
+ {
+ if (\is_string($value)) {
+ return $value;
+ }
+ if (\is_scalar($value) || $value === null) {
+ return (string) $value;
+ }
+
+ return '';
+ }
+
+ /**
+ * Apply an arithmetic result unless it crosses an optional bound.
*
* @param bool $isUpper true = bound is a maximum, false = minimum
*/
- protected function applyNumericLimit(int|float $value, int|float|null $bound, bool $isUpper): int|float
+ protected function applyNumericLimit(mixed $original, mixed $candidate, mixed $bound, bool $isUpper): int|float|string
{
- if ($bound === null) {
- return $value;
+ if (BigInt::isIntegerValue($original) && BigInt::isIntegerValue($candidate) && BigInt::isIntegerValue($bound)) {
+ $crossed = $isUpper
+ ? BigInt::compare($candidate, $bound) > 0
+ : BigInt::compare($candidate, $bound) < 0;
+
+ return $crossed ? BigInt::toNative($original) : $candidate;
+ }
+
+ $numericOriginal = \is_numeric($original) ? $original + 0 : 0;
+ $numericCandidate = \is_numeric($candidate) ? $candidate + 0 : 0;
+ $numericBound = \is_numeric($bound) ? $bound + 0 : null;
+
+ if ($numericBound !== null && (($isUpper && $numericCandidate > $numericBound) || (! $isUpper && $numericCandidate < $numericBound))) {
+ return $numericOriginal;
}
- return $isUpper ? \min($value, $bound) : \max($value, $bound);
+ return $this->preserveNumericType($numericOriginal, $numericCandidate);
}
/**
@@ -3696,20 +3576,20 @@ protected function coerceArray(mixed $value): array
}
/**
- * Mirror Operator::TYPE_ARRAY_FILTER's case-by-case predicate translation
+ * Mirror OperatorType::ArrayFilter's case-by-case predicate translation
* (see MariaDB JSON_TABLE filter — `equal`, `greaterThan`, `isNull`, ...).
*/
protected function matchesArrayFilter(mixed $item, string $condition, mixed $compare): bool
{
return match ($condition) {
- Query::TYPE_EQUAL => $item == $compare,
- Query::TYPE_NOT_EQUAL => $item != $compare,
- Query::TYPE_GREATER => \is_numeric($item) && \is_numeric($compare) && $item + 0 > $compare + 0,
- Query::TYPE_GREATER_EQUAL => \is_numeric($item) && \is_numeric($compare) && $item + 0 >= $compare + 0,
- Query::TYPE_LESSER => \is_numeric($item) && \is_numeric($compare) && $item + 0 < $compare + 0,
- Query::TYPE_LESSER_EQUAL => \is_numeric($item) && \is_numeric($compare) && $item + 0 <= $compare + 0,
- Query::TYPE_IS_NULL => $item === null,
- Query::TYPE_IS_NOT_NULL => $item !== null,
+ Method::Equal->value => $item == $compare,
+ Method::NotEqual->value => $item != $compare,
+ Method::GreaterThan->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 > $compare + 0,
+ Method::GreaterThanEqual->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 >= $compare + 0,
+ Method::LessThan->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 < $compare + 0,
+ Method::LessThanEqual->value => \is_numeric($item) && \is_numeric($compare) && $item + 0 <= $compare + 0,
+ Method::IsNull->value => $item === null,
+ Method::IsNotNull->value => $item !== null,
default => true,
};
}
@@ -3724,10 +3604,11 @@ protected function shiftDate(mixed $current, int $seconds): ?string
if ($current === null) {
return null;
}
+ $stringValue = $this->stringValue($current);
try {
- $base = new \DateTime((string) $current);
+ $base = new \DateTime($stringValue);
} catch (\Throwable) {
- return $current === '' ? null : (string) $current;
+ return $stringValue === '' ? null : $stringValue;
}
$base->modify(($seconds >= 0 ? '+' : '').$seconds.' seconds');
diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php
index 136ebac0f2..7627e6bc08 100644
--- a/src/Database/Adapter/Mongo.php
+++ b/src/Database/Adapter/Mongo.php
@@ -2,15 +2,22 @@
namespace Utopia\Database\Adapter;
+use DateTime as NativeDateTime;
+use DateTimeZone;
use Exception;
+use MongoDB\BSON\Int64;
use MongoDB\BSON\Regex;
use MongoDB\BSON\UTCDateTime;
use stdClass;
+use Throwable;
use Utopia\Database\Adapter;
+use Utopia\Database\Attribute;
+use Utopia\Database\Capability;
use Utopia\Database\Change;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
+use Utopia\Database\Event;
use Utopia\Database\Exception as DatabaseException;
use Utopia\Database\Exception\Authorization as AuthorizationException;
use Utopia\Database\Exception\Conflict as ConflictException;
@@ -18,18 +25,35 @@
use Utopia\Database\Exception\Limit as LimitException;
use Utopia\Database\Exception\Relationship as RelationshipException;
use Utopia\Database\Exception\Restricted as RestrictedException;
-use Utopia\Database\Exception\Structure as StructureException;
use Utopia\Database\Exception\Timeout as TimeoutException;
use Utopia\Database\Exception\Transaction as TransactionException;
use Utopia\Database\Exception\Type as TypeException;
use Utopia\Database\Exception\Unique as UniqueException;
+use Utopia\Database\Hook\Mongo\PermissionFilter as MongoPermissionFilter;
+use Utopia\Database\Hook\Mongo\TenantFilter as MongoTenantFilter;
+use Utopia\Database\Hook\Read;
+use Utopia\Database\Index;
use Utopia\Database\Operator;
+use Utopia\Database\OperatorType;
+use Utopia\Database\PermissionType;
use Utopia\Database\Query;
-use Utopia\Database\Validator\Authorization;
+use Utopia\Database\Relationship;
+use Utopia\Database\RelationSide;
+use Utopia\Database\RelationType;
+use Utopia\Database\Storage;
+use Utopia\Database\Validator\BigInt;
use Utopia\Mongo\Client;
use Utopia\Mongo\Exception as MongoException;
-
-class Mongo extends Adapter
+use Utopia\Query\CursorDirection;
+use Utopia\Query\Method;
+use Utopia\Query\OrderDirection;
+use Utopia\Query\Schema\ColumnType;
+use Utopia\Query\Schema\IndexType;
+
+/**
+ * Database adapter for MongoDB, using the Utopia Mongo client for document-based storage.
+ */
+class Mongo extends Adapter implements Feature\InternalCasting, Feature\Relationships, Feature\Timeouts, Feature\Upserts, Feature\UTCCasting
{
/**
* @var array
@@ -53,11 +77,16 @@ class Mongo extends Adapter
'$nor',
'$exists',
'$elemMatch',
- '$exists'
+ '$exists',
];
protected Client $client;
+ /**
+ * @var list
+ */
+ protected array $readHooks = [];
+
/**
* Default batch size for cursor operations
*/
@@ -65,10 +94,13 @@ class Mongo extends Adapter
/**
* Transaction/session state for MongoDB transactions
- * @var array|null $session
+ *
+ * @var array|null
*/
private ?array $session = null; // Store session array from startSession
+
protected int $inTransaction = 0;
+
protected bool $supportForAttributes = true;
/**
@@ -76,7 +108,6 @@ class Mongo extends Adapter
*
* Set connection and settings
*
- * @param Client $client
* @throws MongoException
*/
public function __construct(Client $client)
@@ -99,126 +130,192 @@ public function getDriver(): mixed
return $this->client;
}
- public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void
- {
- if (!$this->getSupportForTimeouts()) {
- return;
- }
+ /**
+ * Get the list of capabilities supported by the MongoDB adapter.
+ *
+ * @return array
+ */
+ public function capabilities(): array
+ {
+ return array_merge(parent::capabilities(), [
+ Capability::Objects,
+ Capability::Fulltext,
+ Capability::TTLIndexes,
+ Capability::Regex,
+ Capability::BatchCreateAttributes,
+ Capability::Caching,
+ Capability::Hostname,
+ Capability::PCRE,
+ Capability::Operators,
+ Capability::TransactionRetries,
+ Capability::Upserts,
+ ]);
+ }
+ /**
+ * Set the maximum execution time for queries.
+ *
+ * @param int $milliseconds Timeout in milliseconds
+ * @param Event $event The event scope for the timeout
+ * @return void
+ */
+ #[\Override]
+ public function setTimeout(int $milliseconds, Event $event = Event::All): void
+ {
$this->timeout = $milliseconds;
}
- public function clearTimeout(string $event): void
+ /**
+ * Clear the query execution timeout.
+ *
+ * @param Event $event The event scope to clear
+ * @return void
+ */
+ #[\Override]
+ public function clearTimeout(Event $event = Event::All): void
{
- parent::clearTimeout($event);
-
$this->timeout = 0;
}
/**
- * @template T
- * @param callable(): T $callback
- * @return T
- * @throws \Throwable
+ * Set whether the adapter supports schema-based attribute definitions.
+ *
+ * @param bool $support Whether to enable attribute support
+ * @return bool
*/
- public function withTransaction(callable $callback): mixed
+ public function setSupportForAttributes(bool $support): bool
{
- // If the database is not a replica set, we can't use transactions
- if (!$this->client->isReplicaSet()) {
- return $callback();
- }
+ $this->supportForAttributes = $support;
+ $this->capabilitySet = null;
- // MongoDB doesn't support nested transactions/savepoints.
- // If already in a transaction, just run the callback directly.
- if ($this->inTransaction > 0) {
- return $callback();
+ return $this->supportForAttributes;
+ }
+
+ public function supports(Capability $feature): bool
+ {
+ if ($feature === Capability::DefinedAttributes) {
+ return $this->supportForAttributes;
}
- // upsert + $setOnInsert hits WriteConflict (E112) under txn snapshot isolation.
- if ($this->skipDuplicates) {
- return $callback();
+ return parent::supports($feature);
+ }
+
+ protected function syncWriteHooks(): void
+ {
+ }
+
+ protected function syncReadHooks(): void
+ {
+ $this->readHooks = [];
+
+ if ($this->sharedTables && $this->tenant !== null) {
+ $this->readHooks[] = new MongoTenantFilter(
+ $this->tenant,
+ $this->sharedTables,
+ fn (string $collection, array $tenants = []) => $this->getTenantFilters($collection, $tenants),
+ );
}
- $sleep = 50_000; // 50 milliseconds
- $retries = 2;
+ if ($this->hasPermissionHook()) {
+ $this->readHooks[] = new MongoPermissionFilter($this->authorization);
+ }
+ }
- for ($attempts = 0; $attempts <= $retries; $attempts++) {
- try {
- $this->startTransaction();
- $result = $callback();
- $this->commitTransaction();
- return $result;
- } catch (\Throwable $action) {
- try {
- $this->rollbackTransaction();
- } catch (\Throwable) {
- // Throw the original exception, not the rollback one
- // Since if it's a duplicate key error, the rollback will fail,
- // and we want to throw the original exception.
- } finally {
- // Ensure state is cleaned up even if rollback fails
- if ($this->session) {
- try {
- $this->client->endSessions([$this->session]);
- } catch (\Throwable $endSessionError) {
- // Ignore errors when ending session during error cleanup
- }
- }
- $this->inTransaction = 0;
- $this->session = null;
- }
+ /**
+ * @param array $filters
+ * @return array
+ */
+ protected function applyReadFilters(array $filters, string $collection, string $forPermission = 'read'): array
+ {
+ $this->syncReadHooks();
+ foreach ($this->readHooks as $hook) {
+ $filters = $hook->applyFilters($filters, $collection, $forPermission);
+ }
- if (
- $action instanceof DuplicateException ||
- $action instanceof RestrictedException ||
- $action instanceof AuthorizationException ||
- $action instanceof RelationshipException ||
- $action instanceof ConflictException ||
- $action instanceof LimitException ||
- $action instanceof TimeoutException
- ) {
- throw $action;
- }
+ return $filters;
+ }
- if ($attempts < $retries) {
- \usleep($sleep * ($attempts + 1));
- continue;
- }
+ /**
+ * Ping Database
+ *
+ * @throws Exception
+ * @throws MongoException
+ */
+ public function ping(): bool
+ {
+ /** @var \stdClass|array|int $result */
+ $result = $this->getClient()->query([
+ 'ping' => 1,
+ 'skipReadConcern' => true,
+ ]);
- throw $action;
- }
+ if ($result instanceof \stdClass && isset($result->ok)) {
+ return (bool) $result->ok;
}
- throw new TransactionException('Failed to execute transaction');
+ return false;
+ }
+
+ /**
+ * Reconnect to the MongoDB server.
+ *
+ * @return void
+ */
+ public function reconnect(): void
+ {
+ $this->client->connect();
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function getClient(): Client
+ {
+ return $this->client;
}
+ /**
+ * Start a new database transaction or increment the nesting counter.
+ *
+ * @return bool
+ *
+ * @throws DatabaseException If the transaction cannot be started.
+ */
public function startTransaction(): bool
{
// If the database is not a replica set, we can't use transactions
- if (!$this->client->isReplicaSet()) {
+ if (! $this->client->isReplicaSet()) {
return true;
}
try {
if ($this->inTransaction === 0) {
- if (!$this->session) {
+ if (! $this->session) {
$this->session = $this->client->startSession(); // Get session array
$this->client->startTransaction($this->session); // Start the transaction
}
}
$this->inTransaction++;
+
return true;
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->session = null;
$this->inTransaction = 0;
- throw new DatabaseException('Failed to start transaction: ' . $e->getMessage(), $e->getCode(), $e);
+ throw new DatabaseException('Failed to start transaction: '.$e->getMessage(), $e->getCode(), $e);
}
}
+ /**
+ * Commit the current database transaction or decrement the nesting counter.
+ *
+ * @return bool
+ *
+ * @throws DatabaseException If the transaction cannot be committed.
+ */
public function commitTransaction(): bool
{
// If the database is not a replica set, we can't use transactions
- if (!$this->client->isReplicaSet()) {
+ if (! $this->client->isReplicaSet()) {
return true;
}
@@ -228,7 +325,7 @@ public function commitTransaction(): bool
}
$this->inTransaction--;
if ($this->inTransaction === 0) {
- if (!$this->session) {
+ if (! $this->session) {
return false;
}
try {
@@ -241,10 +338,11 @@ public function commitTransaction(): bool
$this->client->endSessions([$this->session]);
$this->session = null;
$this->inTransaction = 0; // Reset counter when transaction is already terminated
+
return true;
}
throw $e;
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
throw new DatabaseException($e->getMessage(), $e->getCode(), $e);
} finally {
if ($this->session) {
@@ -255,24 +353,34 @@ public function commitTransaction(): bool
return true;
}
+
return true;
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
// Ensure cleanup on any failure
try {
- $this->client->endSessions([$this->session]);
- } catch (\Throwable $endSessionError) {
+ if ($this->session !== null) {
+ $this->client->endSessions([$this->session]);
+ }
+ } catch (Throwable $endSessionError) {
// Ignore errors when ending session during error cleanup
}
$this->session = null;
$this->inTransaction = 0;
- throw new DatabaseException('Failed to commit transaction: ' . $e->getMessage(), $e->getCode(), $e);
+ throw new DatabaseException('Failed to commit transaction: '.$e->getMessage(), $e->getCode(), $e);
}
}
+ /**
+ * Roll back the current database transaction or decrement the nesting counter.
+ *
+ * @return bool
+ *
+ * @throws DatabaseException If the rollback fails.
+ */
public function rollbackTransaction(): bool
{
// If the database is not a replica set, we can't use transactions
- if (!$this->client->isReplicaSet()) {
+ if (! $this->client->isReplicaSet()) {
return true;
}
@@ -282,13 +390,13 @@ public function rollbackTransaction(): bool
}
$this->inTransaction--;
if ($this->inTransaction === 0) {
- if (!$this->session) {
+ if (! $this->session) {
return false;
}
try {
$this->client->abortTransaction($this->session);
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$e = $this->processException($e);
if ($e instanceof TransactionException) {
@@ -305,85 +413,103 @@ public function rollbackTransaction(): bool
return true;
}
+
return true;
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
try {
- $this->client->endSessions([$this->session]);
- } catch (\Throwable) {
+ if ($this->session !== null) {
+ $this->client->endSessions([$this->session]);
+ }
+ } catch (Throwable) {
// Ignore errors when ending session during error cleanup
}
$this->session = null;
$this->inTransaction = 0;
- throw new DatabaseException('Failed to rollback transaction: ' . $e->getMessage(), $e->getCode(), $e);
+ throw new DatabaseException('Failed to rollback transaction: '.$e->getMessage(), $e->getCode(), $e);
}
}
/**
- * Helper to add transaction/session context to command options if in transaction
- * Includes defensive check to ensure session is valid
+ * @template T
*
- * @param array $options
- * @return array
+ * @param callable(): T $callback
+ * @return T
+ *
+ * @throws Throwable
*/
- private function getTransactionOptions(array $options = []): array
+ public function withTransaction(callable $callback): mixed
{
- if ($this->inTransaction > 0 && $this->session !== null) {
- // Pass the session array directly - the client will handle the transaction state internally
- $options['session'] = $this->session;
+ // If the database is not a replica set, we can't use transactions
+ if (! $this->client->isReplicaSet()) {
+ return $callback();
}
- return $options;
- }
+ // MongoDB doesn't support nested transactions/savepoints.
+ // If already in a transaction, just run the callback directly.
+ if ($this->inTransaction > 0) {
+ return $callback();
+ }
- /**
- * Create a safe MongoDB regex pattern by escaping special characters
- *
- * @param string $value The user input to escape
- * @param string $pattern The pattern template (e.g., ".*%s.*" for contains)
- * @return Regex
- * @throws DatabaseException
- */
- private function createSafeRegex(string $value, string $pattern = '%s', string $flags = 'i'): Regex
- {
- $escaped = preg_quote($value, '/');
-
- // Validate that the pattern doesn't contain injection vectors
- if (preg_match('/\$[a-z]+/i', $escaped)) {
- throw new DatabaseException('Invalid regex pattern: potential injection detected');
+ // upsert + $setOnInsert hits WriteConflict (E112) under txn snapshot isolation.
+ if ($this->skipDuplicates) {
+ return $callback();
}
- $finalPattern = sprintf($pattern, $escaped);
+ $sleep = 50_000;
+ $retries = 2;
- return new Regex($finalPattern, $flags);
- }
+ for ($attempts = 0; $attempts <= $retries; $attempts++) {
+ try {
+ $this->startTransaction();
+ $result = $callback();
+ $this->commitTransaction();
- /**
- * Ping Database
- *
- * @return bool
- * @throws Exception
- * @throws MongoException
- */
- public function ping(): bool
- {
- return $this->getClient()->query([
- 'ping' => 1,
- 'skipReadConcern' => true
- ])->ok ?? false;
- }
+ return $result;
+ } catch (Throwable $action) {
+ try {
+ $this->rollbackTransaction();
+ } catch (Throwable) {
+ // Preserve the operation failure if cleanup fails.
+ } finally {
+ if ($this->session !== null) {
+ try {
+ $this->client->endSessions([$this->session]);
+ } catch (Throwable) {
+ // Cleanup is best-effort; preserve the operation failure.
+ }
+ }
+ $this->inTransaction = 0;
+ $this->session = null;
+ }
- public function reconnect(): void
- {
- $this->client->connect();
+ if (
+ $action instanceof AuthorizationException
+ || $action instanceof ConflictException
+ || $action instanceof DuplicateException
+ || $action instanceof LimitException
+ || $action instanceof RelationshipException
+ || $action instanceof RestrictedException
+ || $action instanceof TimeoutException
+ ) {
+ throw $action;
+ }
+
+ if ($attempts < $retries) {
+ \usleep($sleep * ($attempts + 1));
+
+ continue;
+ }
+
+ throw $action;
+ }
+ }
+
+ throw new TransactionException('Transaction retry loop exited unexpectedly');
}
/**
* Create Database
- *
- * @param string $name
- *
- * @return bool
*/
public function create(string $name): bool
{
@@ -394,25 +520,29 @@ public function create(string $name): bool
* Check if database exists
* Optionally check if collection exists in database
*
- * @param string $database database name
- * @param string|null $collection (optional) collection name
+ * @param string $database database name
+ * @param string|null $collection (optional) collection name
*
- * @return bool
* @throws Exception
*/
public function exists(string $database, ?string $collection = null): bool
{
- if (!\is_null($collection)) {
- $collection = $this->getNamespace() . "_" . $collection;
+ if (! \is_null($collection)) {
+ $collection = $this->getNamespace().'_'.$collection;
try {
// Use listCollections command with filter for O(1) lookup
+ /** @var \stdClass $result */
$result = $this->getClient()->query([
'listCollections' => 1,
- 'filter' => ['name' => $collection]
+ 'filter' => ['name' => $collection],
]);
- return !empty($result->cursor->firstBatch);
- } catch (\Exception $e) {
+ /** @var \stdClass $cursor */
+ $cursor = $result->cursor;
+ /** @var array $firstBatch */
+ $firstBatch = $cursor->firstBatch;
+ return ! empty($firstBatch);
+ } catch (Exception $e) {
return false;
}
}
@@ -424,13 +554,19 @@ public function exists(string $database, ?string $collection = null): bool
* List Databases
*
* @return array
+ *
* @throws Exception
*/
public function list(): array
{
+ /** @var array $list */
$list = [];
- foreach ((array)$this->getClient()->listDatabaseNames() as $value) {
+ /** @var \stdClass $databaseNames */
+ $databaseNames = $this->getClient()->listDatabaseNames();
+ /** @var array $databaseNamesArray */
+ $databaseNamesArray = (array) $databaseNames;
+ foreach ($databaseNamesArray as $value) {
$list[] = $value;
}
@@ -440,9 +576,7 @@ public function list(): array
/**
* Delete Database
*
- * @param string $name
*
- * @return bool
* @throws Exception
*/
public function delete(string $name): bool
@@ -455,20 +589,19 @@ public function delete(string $name): bool
/**
* Create Collection
*
- * @param string $name
- * @param array $attributes
- * @param array $indexes
- * @return bool
+ * @param array $attributes
+ * @param array $indexes
+ *
* @throws Exception
*/
public function createCollection(string $name, array $attributes = [], array $indexes = []): bool
{
- $id = $this->getNamespace() . '_' . $this->filter($name);
+ $id = $this->getNamespace().'_'.$this->filter($name);
// In shared-tables mode or for metadata, the physical collection may
// already exist for another tenant. Return early to avoid a
// "Collection Exists" exception from the client.
- if (!$this->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) {
+ if (! $this->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) {
return true;
}
@@ -477,6 +610,10 @@ public function createCollection(string $name, array $attributes = [], array $in
$options = $this->getTransactionOptions();
$this->getClient()->createCollection($id, $options);
} catch (MongoException $e) {
+ // Client throws "Collection Exists" (code 0) if it already exists
+ if (\str_contains($e->getMessage(), 'Collection Exists')) {
+ return true;
+ }
$e = $this->processException($e);
if ($e instanceof DuplicateException) {
if ($this->getSharedTables() || $name === Database::METADATA) {
@@ -499,8 +636,8 @@ public function createCollection(string $name, array $attributes = [], array $in
$internalIndex = [
[
- 'key' => ['_uid' => $this->getOrder(Database::ORDER_ASC)],
- 'name' => '_uid',
+ 'key' => [Storage::UID => $this->getOrder(OrderDirection::Asc)],
+ 'name' => Storage::UID,
'unique' => true,
'collation' => [
'locale' => 'en',
@@ -508,22 +645,22 @@ public function createCollection(string $name, array $attributes = [], array $in
],
],
[
- 'key' => ['_createdAt' => $this->getOrder(Database::ORDER_ASC)],
- 'name' => '_createdAt',
+ 'key' => [Storage::CREATED_AT => $this->getOrder(OrderDirection::Asc)],
+ 'name' => Storage::CREATED_AT,
],
[
- 'key' => ['_updatedAt' => $this->getOrder(Database::ORDER_ASC)],
- 'name' => '_updatedAt',
+ 'key' => [Storage::UPDATED_AT => $this->getOrder(OrderDirection::Asc)],
+ 'name' => Storage::UPDATED_AT,
],
[
- 'key' => ['_permissions' => $this->getOrder(Database::ORDER_ASC)],
- 'name' => '_permissions',
- ]
+ 'key' => [Storage::PERMISSIONS => $this->getOrder(OrderDirection::Asc)],
+ 'name' => Storage::PERMISSIONS,
+ ],
];
if ($this->sharedTables) {
foreach ($internalIndex as &$index) {
- $index['key'] = array_merge(['_tenant' => $this->getOrder(Database::ORDER_ASC)], $index['key']);
+ $index['key'] = array_merge([Storage::TENANT => $this->getOrder(OrderDirection::Asc)], $index['key']);
}
unset($index);
}
@@ -531,18 +668,18 @@ public function createCollection(string $name, array $attributes = [], array $in
try {
$options = $this->getTransactionOptions();
$indexesCreated = $this->client->createIndexes($id, $internalIndex, $options);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
throw $this->processException($e);
}
- if (!$indexesCreated) {
+ if (! $indexesCreated) {
return false;
}
// Since attributes are not used by this adapter
// Only act when $indexes is provided
- if (!empty($indexes)) {
+ if (! empty($indexes)) {
/**
* Each new index has format ['key' => [$attribute => $order], 'name' => $name, 'unique' => $unique]
*/
@@ -555,32 +692,31 @@ public function createCollection(string $name, array $attributes = [], array $in
$key = [];
$unique = false;
- $attributes = $index->getAttribute('attributes');
- $orders = $index->getAttribute('orders');
+ $attributes = $index->attributes;
+ $orders = $index->orders;
// If sharedTables, always add _tenant as the first key
if ($this->shouldAddTenantToIndex($index)) {
- $key['_tenant'] = $this->getOrder(Database::ORDER_ASC);
+ $key[Storage::TENANT] = $this->getOrder(OrderDirection::Asc);
}
foreach ($attributes as $j => $attribute) {
- $attribute = $this->filter($this->getInternalKeyForAttribute($attribute));
+ $attribute = $this->filter($this->getInternalKeyForAttribute((string) $attribute));
- switch ($index->getAttribute('type')) {
- case Database::INDEX_KEY:
- $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC));
+ switch ($index->type) {
+ case IndexType::Key:
+ $order = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$j] ?? null)) ?? OrderDirection::Asc);
break;
- case Database::INDEX_FULLTEXT:
+ case IndexType::Fulltext:
// MongoDB fulltext index is just 'text'
- // Not using Database::INDEX_KEY for clarity
$order = 'text';
break;
- case Database::INDEX_UNIQUE:
- $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC));
+ case IndexType::Unique:
+ $order = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$j] ?? null)) ?? OrderDirection::Asc);
$unique = true;
break;
- case Database::INDEX_TTL:
- $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC));
+ case IndexType::Ttl:
+ $order = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$j] ?? null)) ?? OrderDirection::Asc);
break;
default:
// index not supported
@@ -592,34 +728,35 @@ public function createCollection(string $name, array $attributes = [], array $in
$newIndexes[$i] = [
'key' => $key,
- 'name' => $this->filter($index->getId()),
- 'unique' => $unique
+ 'name' => $this->filter($index->key),
+ 'unique' => $unique,
];
- if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) {
+ if ($index->type === IndexType::Fulltext) {
$newIndexes[$i]['default_language'] = 'none';
}
// Handle TTL indexes
- if ($index->getAttribute('type') === Database::INDEX_TTL) {
- $ttl = $index->getAttribute('ttl', 0);
+ if ($index->type === IndexType::Ttl) {
+ $ttl = $index->ttl;
if ($ttl > 0) {
$newIndexes[$i]['expireAfterSeconds'] = $ttl;
}
}
// Add partial filter for indexes to avoid indexing null values
- if (in_array($index->getAttribute('type'), [
- Database::INDEX_UNIQUE,
- Database::INDEX_KEY
+ if (in_array($index->type, [
+ IndexType::Unique,
+ IndexType::Key,
])) {
$partialFilter = [];
foreach ($attributes as $attr) {
+ $attr = (string) $attr;
// Find the matching attribute in collectionAttributes to get its type
$attrType = 'string'; // Default fallback
foreach ($collectionAttributes as $collectionAttr) {
- if ($collectionAttr->getId() === $attr) {
- $attrType = $this->getMongoTypeCode($collectionAttr->getAttribute('type'));
+ if ($collectionAttr->key === $attr) {
+ $attrType = $this->getMongoTypeCode($collectionAttr->type);
break;
}
}
@@ -629,10 +766,10 @@ public function createCollection(string $name, array $attributes = [], array $in
// Use both $exists: true and $type to exclude nulls and ensure correct type
$partialFilter[$attr] = [
'$exists' => true,
- '$type' => $attrType
+ '$type' => $attrType,
];
}
- if (!empty($partialFilter)) {
+ if (! empty($partialFilter)) {
$newIndexes[$i]['partialFilterExpression'] = $partialFilter;
}
}
@@ -640,12 +777,12 @@ public function createCollection(string $name, array $attributes = [], array $in
try {
$options = $this->getTransactionOptions();
- $indexesCreated = $this->getClient()->createIndexes($id, $newIndexes, $options);
- } catch (\Exception $e) {
+ $indexesCreated = $this->getClient()->createIndexes($id, \array_values($newIndexes), $options);
+ } catch (Exception $e) {
throw $this->processException($e);
}
- if (!$indexesCreated) {
+ if (! $indexesCreated) {
return false;
}
}
@@ -657,15 +794,21 @@ public function createCollection(string $name, array $attributes = [], array $in
* List Collections
*
* @return array
+ *
* @throws Exception
*/
public function listCollections(): array
{
+ /** @var array $list */
$list = [];
// Note: listCollections is a metadata operation that should not run in transactions
// to avoid transaction conflicts and readConcern issues
- foreach ((array)$this->getClient()->listCollectionNames() as $value) {
+ /** @var \stdClass $collectionNames */
+ $collectionNames = $this->getClient()->listCollectionNames();
+ /** @var array $collectionNamesArray */
+ $collectionNamesArray = (array) $collectionNames;
+ foreach ($collectionNamesArray as $value) {
$list[] = $value;
}
@@ -673,111 +816,67 @@ public function listCollections(): array
}
/**
- * Get Collection Size on disk
- * @param string $collection
- * @return int
- * @throws DatabaseException
+ * Delete Collection
+ *
+ * @throws Exception
*/
- public function getSizeOfCollectionOnDisk(string $collection): int
+ public function deleteCollection(string $id): bool
{
- return $this->getSizeOfCollection($collection);
+ $id = $this->getNamespace().'_'.$this->filter($id);
+
+ return (bool) $this->getClient()->dropCollection($id);
}
/**
- * Get Collection Size of raw data
- * @param string $collection
- * @return int
- * @throws DatabaseException
+ * Analyze a collection updating it's metadata on the database engine
*/
- public function getSizeOfCollection(string $collection): int
+ public function analyzeCollection(string $collection): bool
{
- $namespace = $this->getNamespace();
- $collection = $this->filter($collection);
- $collection = $namespace . '_' . $collection;
-
- $command = [
- 'collStats' => $collection,
- 'scale' => 1
- ];
-
- try {
- $result = $this->getClient()->query($command);
- if (is_object($result)) {
- return $result->totalSize;
- } else {
- throw new DatabaseException('No size found');
- }
- } catch (Exception $e) {
- throw new DatabaseException('Failed to get collection size: ' . $e->getMessage());
- }
+ return false;
}
/**
- * Delete Collection
- *
- * @param string $id
- * @return bool
- * @throws Exception
+ * Create Attribute
*/
- public function deleteCollection(string $id): bool
+ public function createAttribute(string $collection, Attribute $attribute): bool
{
- $id = $this->getNamespace() . '_' . $this->filter($id);
- return (!!$this->getClient()->dropCollection($id));
+ return true;
}
/**
- * Analyze a collection updating it's metadata on the database engine
+ * Create Attributes
*
- * @param string $collection
- * @return bool
- */
- public function analyzeCollection(string $collection): bool
- {
- return false;
- }
-
- /**
- * Create Attribute
+ * @param array $attributes
*
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param int $size
- * @param bool $signed
- * @param bool $array
- * @return bool
+ * @throws DatabaseException
*/
- public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool
+ public function createAttributes(string $collection, array $attributes): bool
{
return true;
}
/**
- * Create Attributes
- *
- * @param string $collection
- * @param array> $attributes
- * @return bool
- * @throws DatabaseException
+ * Update Attribute.
*/
- public function createAttributes(string $collection, array $attributes): bool
+ public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool
{
+ if (! empty($newKey) && $newKey !== $attribute->key) {
+ return $this->renameAttribute($collection, $attribute->key, $newKey);
+ }
+
return true;
}
/**
* Delete Attribute
*
- * @param string $collection
- * @param string $id
*
- * @return bool
* @throws DatabaseException
* @throws MongoException
*/
public function deleteAttribute(string $collection, string $id): bool
{
- $collection = $this->getNamespace() . '_' . $this->filter($collection);
+ $collection = $this->getNamespace().'_'.$this->filter($collection);
$this->getClient()->update(
$collection,
@@ -792,19 +891,15 @@ public function deleteAttribute(string $collection, string $id): bool
/**
* Rename Attribute.
*
- * @param string $collection
- * @param string $id
- * @param string $name
- * @return bool
* @throws DatabaseException
* @throws MongoException
*/
public function renameAttribute(string $collection, string $id, string $name): bool
{
- $collection = $this->getNamespace() . '_' . $this->filter($collection);
+ $collection = $this->getNamespace().'_'.$this->filter($collection);
- $from = $this->filter($this->getInternalKeyForAttribute($id));
- $to = $this->filter($this->getInternalKeyForAttribute($name));
+ $from = $this->filter($this->getInternalKeyForAttribute($id));
+ $to = $this->filter($this->getInternalKeyForAttribute($name));
$options = $this->getTransactionOptions();
$this->getClient()->update(
@@ -819,100 +914,81 @@ public function renameAttribute(string $collection, string $id, string $name): b
}
/**
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $id
- * @param string $twoWayKey
+ * Create a relationship between collections. No-op for MongoDB since relationships are virtual.
+ *
+ * @param Relationship $relationship The relationship definition
* @return bool
*/
- public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool
+ public function createRelationship(Relationship $relationship): bool
{
return true;
}
/**
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $key
- * @param string $twoWayKey
- * @param string $side
- * @param string|null $newKey
- * @param string|null $newTwoWayKey
- * @return bool
* @throws DatabaseException
* @throws MongoException
*/
public function updateRelationship(
- string $collection,
- string $relatedCollection,
- string $type,
- bool $twoWay,
- string $key,
- string $twoWayKey,
- string $side,
+ Relationship $relationship,
?string $newKey = null,
?string $newTwoWayKey = null
): bool {
- $collectionName = $this->getNamespace() . '_' . $this->filter($collection);
- $relatedCollectionName = $this->getNamespace() . '_' . $this->filter($relatedCollection);
+ $collectionName = $this->getNamespace().'_'.$this->filter($relationship->collection);
+ $relatedCollectionName = $this->getNamespace().'_'.$this->filter($relationship->relatedCollection);
- $escapedKey = $this->escapeMongoFieldName($key);
- $escapedNewKey = !\is_null($newKey) ? $this->escapeMongoFieldName($newKey) : null;
- $escapedTwoWayKey = $this->escapeMongoFieldName($twoWayKey);
- $escapedNewTwoWayKey = !\is_null($newTwoWayKey) ? $this->escapeMongoFieldName($newTwoWayKey) : null;
+ $escapedKey = $this->escapeMongoFieldName($relationship->key);
+ $escapedNewKey = ! \is_null($newKey) ? $this->escapeMongoFieldName($newKey) : null;
+ $escapedTwoWayKey = $this->escapeMongoFieldName($relationship->twoWayKey);
+ $escapedNewTwoWayKey = ! \is_null($newTwoWayKey) ? $this->escapeMongoFieldName($newTwoWayKey) : null;
$renameKey = [
'$rename' => [
$escapedKey => $escapedNewKey,
- ]
+ ],
];
$renameTwoWayKey = [
'$rename' => [
$escapedTwoWayKey => $escapedNewTwoWayKey,
- ]
+ ],
];
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
- if (!\is_null($newKey) && $key !== $newKey) {
+ switch ($relationship->type) {
+ case RelationType::OneToOne:
+ if (! \is_null($newKey) && $relationship->key !== $newKey) {
$this->getClient()->update($collectionName, updates: $renameKey, multi: true);
}
- if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) {
+ if ($relationship->twoWay && ! \is_null($newTwoWayKey) && $relationship->twoWayKey !== $newTwoWayKey) {
$this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true);
}
break;
- case Database::RELATION_ONE_TO_MANY:
- if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) {
+ case RelationType::OneToMany:
+ if ($relationship->twoWay && ! \is_null($newTwoWayKey) && $relationship->twoWayKey !== $newTwoWayKey) {
$this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true);
}
break;
- case Database::RELATION_MANY_TO_ONE:
- if (!\is_null($newKey) && $key !== $newKey) {
+ case RelationType::ManyToOne:
+ if (! \is_null($newKey) && $relationship->key !== $newKey) {
$this->getClient()->update($collectionName, updates: $renameKey, multi: true);
}
break;
- case Database::RELATION_MANY_TO_MANY:
- $metadataCollection = new Document(['$id' => Database::METADATA]);
- $collectionDoc = $this->getDocument($metadataCollection, $collection);
- $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection);
+ case RelationType::ManyToMany:
+ $metadataCollection = new Document([Document::ID => Database::METADATA]);
+ $collectionDoc = $this->getDocument($metadataCollection, $relationship->collection);
+ $relatedCollectionDoc = $this->getDocument($metadataCollection, $relationship->relatedCollection);
if ($collectionDoc->isEmpty() || $relatedCollectionDoc->isEmpty()) {
throw new DatabaseException('Collection or related collection not found');
}
- $junction = $side === Database::RELATION_SIDE_PARENT
- ? $this->getNamespace() . '_' . $this->filter('_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence())
- : $this->getNamespace() . '_' . $this->filter('_' . $relatedCollectionDoc->getSequence() . '_' . $collectionDoc->getSequence());
+ $junction = $relationship->side === RelationSide::Parent
+ ? $this->getNamespace().'_'.$this->filter('_'.$collectionDoc->getSequence().'_'.$relatedCollectionDoc->getSequence())
+ : $this->getNamespace().'_'.$this->filter('_'.$relatedCollectionDoc->getSequence().'_'.$collectionDoc->getSequence());
- if (!\is_null($newKey) && $key !== $newKey) {
+ if (! \is_null($newKey) && $relationship->key !== $newKey) {
$this->getClient()->update($junction, updates: $renameKey, multi: true);
}
- if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) {
+ if ($relationship->twoWay && ! \is_null($newTwoWayKey) && $relationship->twoWayKey !== $newTwoWayKey) {
$this->getClient()->update($junction, updates: $renameTwoWayKey, multi: true);
}
break;
@@ -924,71 +1000,57 @@ public function updateRelationship(
}
/**
- * @param string $collection
- * @param string $relatedCollection
- * @param string $type
- * @param bool $twoWay
- * @param string $key
- * @param string $twoWayKey
- * @param string $side
- * @return bool
* @throws MongoException
* @throws Exception
*/
public function deleteRelationship(
- string $collection,
- string $relatedCollection,
- string $type,
- bool $twoWay,
- string $key,
- string $twoWayKey,
- string $side
+ Relationship $relationship
): bool {
- $collectionName = $this->getNamespace() . '_' . $this->filter($collection);
- $relatedCollectionName = $this->getNamespace() . '_' . $this->filter($relatedCollection);
- $escapedKey = $this->escapeMongoFieldName($key);
- $escapedTwoWayKey = $this->escapeMongoFieldName($twoWayKey);
-
- switch ($type) {
- case Database::RELATION_ONE_TO_ONE:
- if ($side === Database::RELATION_SIDE_PARENT) {
+ $collectionName = $this->getNamespace().'_'.$this->filter($relationship->collection);
+ $relatedCollectionName = $this->getNamespace().'_'.$this->filter($relationship->relatedCollection);
+ $escapedKey = $this->escapeMongoFieldName($relationship->key);
+ $escapedTwoWayKey = $this->escapeMongoFieldName($relationship->twoWayKey);
+
+ switch ($relationship->type) {
+ case RelationType::OneToOne:
+ if ($relationship->side === RelationSide::Parent) {
$this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true);
- if ($twoWay) {
+ if ($relationship->twoWay) {
$this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true);
}
- } elseif ($side === Database::RELATION_SIDE_CHILD) {
+ } elseif ($relationship->side === RelationSide::Child) {
$this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true);
- if ($twoWay) {
+ if ($relationship->twoWay) {
$this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true);
}
}
break;
- case Database::RELATION_ONE_TO_MANY:
- if ($side === Database::RELATION_SIDE_PARENT) {
+ case RelationType::OneToMany:
+ if ($relationship->side === RelationSide::Parent) {
$this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true);
} else {
$this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true);
}
break;
- case Database::RELATION_MANY_TO_ONE:
- if ($side === Database::RELATION_SIDE_PARENT) {
+ case RelationType::ManyToOne:
+ if ($relationship->side === RelationSide::Parent) {
$this->getClient()->update($collectionName, [], ['$unset' => [$escapedKey => '']], multi: true);
} else {
$this->getClient()->update($relatedCollectionName, [], ['$unset' => [$escapedTwoWayKey => '']], multi: true);
}
break;
- case Database::RELATION_MANY_TO_MANY:
- $metadataCollection = new Document(['$id' => Database::METADATA]);
- $collectionDoc = $this->getDocument($metadataCollection, $collection);
- $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection);
+ case RelationType::ManyToMany:
+ $metadataCollection = new Document([Document::ID => Database::METADATA]);
+ $collectionDoc = $this->getDocument($metadataCollection, $relationship->collection);
+ $relatedCollectionDoc = $this->getDocument($metadataCollection, $relationship->relatedCollection);
if ($collectionDoc->isEmpty() || $relatedCollectionDoc->isEmpty()) {
throw new DatabaseException('Collection or related collection not found');
}
- $junction = $side === Database::RELATION_SIDE_PARENT
- ? $this->getNamespace() . '_' . $this->filter('_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence())
- : $this->getNamespace() . '_' . $this->filter('_' . $relatedCollectionDoc->getSequence() . '_' . $collectionDoc->getSequence());
+ $junction = $relationship->side === RelationSide::Parent
+ ? $this->getNamespace().'_'.$this->filter('_'.$collectionDoc->getSequence().'_'.$relatedCollectionDoc->getSequence())
+ : $this->getNamespace().'_'.$this->filter('_'.$relatedCollectionDoc->getSequence().'_'.$collectionDoc->getSequence());
$this->getClient()->dropCollection($junction);
break;
@@ -1002,34 +1064,36 @@ public function deleteRelationship(
/**
* Create Index
*
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param array $attributes
- * @param array $lengths
- * @param array $orders
- * @param array $indexAttributeTypes
- * @param array $collation
- * @param int $ttl
- * @return bool
+ * @param array $indexAttributeTypes
+ * @param array $collation
+ *
* @throws Exception
*/
- public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool
- {
- $name = $this->getNamespace() . '_' . $this->filter($collection);
- $id = $this->filter($id);
+ public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool
+ {
+ $name = $this->getNamespace().'_'.$this->filter($collection);
+ $id = $this->filter($index->key);
+ $type = $index->type;
+ $attributes = $index->attributes;
+ $orders = $index->orders;
+ $ttl = $index->ttl;
+ /** @var array $indexes */
$indexes = [];
$options = [];
$indexes['name'] = $id;
+ /** @var array $indexKey */
+ $indexKey = [];
+
// If sharedTables, always add _tenant as the first key
if ($this->shouldAddTenantToIndex($type)) {
- $indexes['key']['_tenant'] = $this->getOrder(Database::ORDER_ASC);
+ $indexKey[Storage::TENANT] = $this->getOrder(OrderDirection::Asc);
}
foreach ($attributes as $i => $attribute) {
+ $attribute = (string) $attribute;
- if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === Database::VAR_OBJECT) {
+ if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === ColumnType::Object->value) {
$dottedAttributes = \explode('.', $attribute);
$expandedAttributes = array_map(fn ($attr) => $this->filter($attr), $dottedAttributes);
$attributes[$i] = implode('.', $expandedAttributes);
@@ -1037,33 +1101,35 @@ public function createIndex(string $collection, string $id, string $type, array
$attributes[$i] = $this->filter($this->getInternalKeyForAttribute($attribute));
}
- $orderType = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC));
- $indexes['key'][$attributes[$i]] = $orderType;
+ $orderType = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$i] ?? null)) ?? OrderDirection::Asc);
+ $indexKey[$attributes[$i]] = $orderType;
switch ($type) {
- case Database::INDEX_KEY:
+ case IndexType::Key:
break;
- case Database::INDEX_FULLTEXT:
- $indexes['key'][$attributes[$i]] = 'text';
+ case IndexType::Fulltext:
+ $indexKey[$attributes[$i]] = 'text';
break;
- case Database::INDEX_UNIQUE:
+ case IndexType::Unique:
$indexes['unique'] = true;
break;
- case Database::INDEX_TTL:
+ case IndexType::Ttl:
break;
default:
return false;
}
}
+ $indexes['key'] = $indexKey;
+
/**
* Collation
* 1. Moved under $indexes.
* 2. Updated format.
* 3. Avoid adding collation to fulltext index
*/
- if (!empty($collation) &&
- $type !== Database::INDEX_FULLTEXT) {
+ if (! empty($collation) &&
+ $type !== IndexType::Fulltext) {
$indexes['collation'] = [
'locale' => 'en',
'strength' => 1,
@@ -1075,24 +1141,24 @@ public function createIndex(string $collection, string $id, string $type, array
* Set to 'none' to disable stop words (words like 'other', 'the', 'a', etc.)
* This ensures all words are indexed and searchable
*/
- if ($type === Database::INDEX_FULLTEXT) {
+ if ($type === IndexType::Fulltext) {
$indexes['default_language'] = 'none';
}
// Handle TTL indexes
- if ($type === Database::INDEX_TTL && $ttl > 0) {
+ if ($type === IndexType::Ttl && $ttl > 0) {
$indexes['expireAfterSeconds'] = $ttl;
}
// Add partial filter for indexes to avoid indexing null values
- if (in_array($type, [Database::INDEX_UNIQUE, Database::INDEX_KEY])) {
+ if (in_array($type, [IndexType::Unique, IndexType::Key])) {
$partialFilter = [];
foreach ($attributes as $i => $attr) {
- $attrType = $indexAttributeTypes[$i] ?? Database::VAR_STRING; // Default to string if type not provided
+ $attrType = Attribute::tryNormalizeType($indexAttributeTypes[$i] ?? '') ?? ColumnType::String;
$attrType = $this->getMongoTypeCode($attrType);
$partialFilter[$attr] = ['$exists' => true, '$type' => $attrType];
}
- if (!empty($partialFilter)) {
+ if (! empty($partialFilter)) {
$indexes['partialFilterExpression'] = $partialFilter;
}
}
@@ -1102,7 +1168,7 @@ public function createIndex(string $collection, string $id, string $type, array
// Wait for unique index to be fully built before returning
// MongoDB builds indexes asynchronously, so we need to wait for completion
// to ensure unique constraints are enforced immediately
- if ($type === Database::INDEX_UNIQUE) {
+ if ($type === IndexType::Unique) {
$maxRetries = 10;
$retryCount = 0;
$baseDelay = 50000; // 50ms
@@ -1110,26 +1176,31 @@ public function createIndex(string $collection, string $id, string $type, array
while ($retryCount < $maxRetries) {
try {
+ /** @var \stdClass $indexList */
$indexList = $this->client->query([
- 'listIndexes' => $name
+ 'listIndexes' => $name,
]);
- if (isset($indexList->cursor->firstBatch)) {
- foreach ($indexList->cursor->firstBatch as $existingIndex) {
+ /** @var \stdClass $indexListCursor */
+ $indexListCursor = $indexList->cursor;
+ if (isset($indexListCursor->firstBatch)) {
+ /** @var array $firstBatch */
+ $firstBatch = $indexListCursor->firstBatch;
+ foreach ($firstBatch as $existingIndex) {
$indexArray = $this->client->toArray($existingIndex);
if (
(isset($indexArray['name']) && $indexArray['name'] === $id) &&
- (!isset($indexArray['buildState']) || $indexArray['buildState'] === 'ready')
+ (! isset($indexArray['buildState']) || $indexArray['buildState'] === 'ready')
) {
return $result;
}
}
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
if ($retryCount >= $maxRetries - 1) {
throw new DatabaseException(
- 'Timeout waiting for index creation: ' . $e->getMessage(),
+ 'Timeout waiting for index creation: '.$e->getMessage(),
$e->getCode(),
$e
);
@@ -1137,7 +1208,7 @@ public function createIndex(string $collection, string $id, string $type, array
}
$delay = \min($baseDelay * (2 ** $retryCount), $maxDelay);
- \usleep((int)$delay);
+ \usleep((int) $delay);
$retryCount++;
}
@@ -1145,33 +1216,50 @@ public function createIndex(string $collection, string $id, string $type, array
}
return $result;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
throw $this->processException($e);
}
}
+ /**
+ * Delete Index
+ *
+ *
+ * @throws Exception
+ */
+ public function deleteIndex(string $collection, string $id): bool
+ {
+ $name = $this->getNamespace().'_'.$this->filter($collection);
+ $id = $this->filter($id);
+ $this->getClient()->dropIndexes($name, [$id]);
+
+ return true;
+ }
+
/**
* Rename Index.
*
- * @param string $collection
- * @param string $old
- * @param string $new
*
- * @return bool
* @throws Exception
*/
public function renameIndex(string $collection, string $old, string $new): bool
{
$collection = $this->filter($collection);
- $metadataCollection = new Document(['$id' => Database::METADATA]);
+ $metadataCollection = new Document([Document::ID => Database::METADATA]);
$collectionDocument = $this->getDocument($metadataCollection, $collection);
$old = $this->filter($old);
$new = $this->filter($new);
- $indexes = json_decode($collectionDocument['indexes'], true);
+ $rawIndexes = $collectionDocument->getAttribute('indexes', '[]');
+ /** @var array> $indexes */
+ $indexes = json_decode((string) (is_string($rawIndexes) ? $rawIndexes : '[]'), true) ?? [];
+ /** @var array|null $index */
$index = null;
foreach ($indexes as $node) {
- if (($node['$id'] ?? $node['key'] ?? '') === $old) {
+ /** @var array $node */
+ $nodeId = $node[Document::ID] ?? $node['key'] ?? '';
+ $nodeIdStr = \is_string($nodeId) ? $nodeId : (\is_scalar($nodeId) ? (string) $nodeId : '');
+ if ($nodeIdStr === $old) {
$index = $node;
break;
}
@@ -1179,14 +1267,22 @@ public function renameIndex(string $collection, string $old, string $new): bool
// Extract attribute types from the collection document
$indexAttributeTypes = [];
- if (isset($collectionDocument['attributes'])) {
- $attributes = json_decode($collectionDocument['attributes'], true);
+ $rawAttributes = $collectionDocument->getAttribute('attributes');
+ if ($rawAttributes !== null) {
+ /** @var array> $attributes */
+ $attributes = json_decode((string) (is_string($rawAttributes) ? $rawAttributes : '[]'), true) ?? [];
if ($attributes && $index) {
// Map index attributes to their types
- foreach ($index['attributes'] as $attrName) {
+ /** @var array $indexAttrs */
+ $indexAttrs = $index['attributes'] ?? [];
+ foreach ($indexAttrs as $attrName) {
foreach ($attributes as $attr) {
- if ($attr['key'] === $attrName) {
- $indexAttributeTypes[$attrName] = $attr['type'];
+ /** @var array $attr */
+ $attrKey = $attr['key'] ?? '';
+ $attrKeyStr = \is_string($attrKey) ? $attrKey : (\is_scalar($attrKey) ? (string) $attrKey : '');
+ if ($attrKeyStr === $attrName) {
+ $attrType = $attr['type'] ?? '';
+ $indexAttributeTypes[$attrName] = \is_string($attrType) ? $attrType : (\is_scalar($attrType) ? (string) $attrType : '');
break;
}
}
@@ -1195,12 +1291,27 @@ public function renameIndex(string $collection, string $old, string $new): bool
}
try {
- if (!$index) {
- throw new DatabaseException('Index not found: ' . $old);
+ if (! $index) {
+ throw new DatabaseException('Index not found: '.$old);
}
$deletedindex = $this->deleteIndex($collection, $old);
- $createdindex = $this->createIndex($collection, $new, $index['type'], $index['attributes'], $index['lengths'] ?? [], $index['orders'] ?? [], $indexAttributeTypes, [], $index['ttl'] ?? 0);
- } catch (\Exception $e) {
+ /** @var array $indexAttributes */
+ $indexAttributes = $index['attributes'] ?? [];
+ /** @var array $indexLengths */
+ $indexLengths = $index['lengths'] ?? [];
+ $rawIndexType = $index['type'] ?? 'key';
+ $indexTypeStr = \is_string($rawIndexType) ? $rawIndexType : (\is_scalar($rawIndexType) ? (string) $rawIndexType : 'key');
+ $rawIndexTtl = $index['ttl'] ?? 0;
+ $indexTtlInt = \is_int($rawIndexTtl) ? $rawIndexTtl : (\is_numeric($rawIndexTtl) ? (int) $rawIndexTtl : 0);
+ $createdindex = $this->createIndex($collection, Index::fromArray([
+ 'key' => $new,
+ 'type' => $indexTypeStr,
+ 'attributes' => $indexAttributes,
+ 'lengths' => $indexLengths,
+ 'orders' => $index['orders'] ?? [],
+ 'ttl' => $indexTtlInt,
+ ]), $indexAttributeTypes);
+ } catch (Exception $e) {
throw $this->processException($e);
}
@@ -1211,56 +1322,37 @@ public function renameIndex(string $collection, string $old, string $new): bool
return false;
}
- /**
- * Delete Index
- *
- * @param string $collection
- * @param string $id
- *
- * @return bool
- * @throws Exception
- */
- public function deleteIndex(string $collection, string $id): bool
- {
- $name = $this->getNamespace() . '_' . $this->filter($collection);
- $id = $this->filter($id);
- $this->getClient()->dropIndexes($name, [$id]);
-
- return true;
- }
-
/**
* Get Document
*
- * @param Document $collection
- * @param string $id
- * @param Query[] $queries
- * @param bool $forUpdate
- * @return Document
+ * @param Query[] $queries
+ *
* @throws DatabaseException
*/
public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document
{
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
- $filters = ['_uid' => $id];
-
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection->getId());
- }
+ $filters = [Storage::UID => $id];
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection->getId());
$options = $this->getTransactionOptions();
$selections = $this->getAttributeSelections($queries);
- $hasProjection = !empty($selections) && !\in_array('*', $selections);
+ $hasProjection = ! empty($selections) && ! \in_array('*', $selections);
if ($hasProjection) {
$options['projection'] = $this->getAttributeProjection($selections);
}
try {
- $result = $this->client->find($name, $filters, $options)->cursor->firstBatch;
+ $findResponse = $this->client->find($name, $filters, $options);
+ /** @var \stdClass $findCursor */
+ $findCursor = $findResponse->cursor;
+ /** @var array $result */
+ $result = $findCursor->firstBatch;
} catch (MongoException $e) {
throw $this->processException($e);
}
@@ -1269,13 +1361,14 @@ public function getDocument(Document $collection, string $id, array $queries = [
return new Document([]);
}
+ /** @var array|null $resultArray */
$resultArray = $this->client->toArray($result[0]);
- $result = $this->replaceChars('_', '$', $resultArray);
+ $result = $this->replaceChars('_', '$', $resultArray ?? []);
$document = new Document($result);
$document = $this->castingAfter($collection, $document);
// Ensure missing relationship attributes are set to null (MongoDB doesn't store null fields)
- if (!$hasProjection) {
+ if (! $hasProjection) {
$this->ensureRelationshipDefaults($collection, $document);
}
@@ -1285,29 +1378,27 @@ public function getDocument(Document $collection, string $id, array $queries = [
/**
* Create Document
*
- * @param Document $collection
- * @param Document $document
*
- * @return Document
* @throws Exception
*/
public function createDocument(Document $collection, Document $document): Document
{
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
+ $this->syncWriteHooks();
- $sequence = $document->getSequence();
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
- $document->removeAttribute('$sequence');
+ $sequence = $document->getSequence();
- if ($this->sharedTables) {
- $document->setAttribute('$tenant', $this->getTenant());
- }
+ $document->removeAttribute(Document::SEQUENCE);
- $record = $this->replaceChars('$', '_', (array)$document);
+ /** @var array $documentArray */
+ $documentArray = (array) $document;
+ $record = $this->replaceChars('$', '_', $documentArray);
+ $record = $this->decorateRow($record, $this->documentMetadata($document));
// Insert manual id if set
- if (!empty($sequence)) {
- $record['_id'] = $sequence;
+ if (! empty($sequence)) {
+ $record[Storage::SEQUENCE] = $sequence;
}
$options = $this->getTransactionOptions();
$result = $this->insertDocument($name, $this->removeNullKeys($record), $options);
@@ -1320,203 +1411,10 @@ public function createDocument(Document $collection, Document $document): Docume
return $document;
}
- /**
- * Returns the document after casting from
- * @param Document $collection
- * @param Document $document
- * @return Document
- */
- public function castingAfter(Document $collection, Document $document): Document
- {
- if (!$this->getSupportForInternalCasting()) {
- return $document;
- }
-
- if ($document->isEmpty()) {
- return $document;
- }
-
- $attributes = $collection->getAttribute('attributes', []);
-
- $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES);
-
- foreach ($attributes as $attribute) {
- $key = $attribute['$id'] ?? '';
- $type = $attribute['type'] ?? '';
- $array = $attribute['array'] ?? false;
- $value = $document->getAttribute($key);
- if (is_null($value)) {
- continue;
- }
-
- // Operators are resolved by the database (aggregation pipeline); skip casting
- if (Operator::isOperator($value)) {
- continue;
- }
-
- if ($array) {
- if (is_string($value)) {
- $decoded = json_decode($value, true);
- if (json_last_error() !== JSON_ERROR_NONE) {
- throw new DatabaseException('Failed to decode JSON for attribute ' . $key . ': ' . json_last_error_msg());
- }
- $value = $decoded;
- }
- } else {
- $value = [$value];
- }
-
- foreach ($value as &$node) {
- switch ($type) {
- case Database::VAR_INTEGER:
- case Database::VAR_BIGINT:
- $node = (int)$node;
- break;
- case Database::VAR_DATETIME:
- $node = $this->convertUTCDateToString($node);
- break;
- case Database::VAR_OBJECT:
- // Convert stdClass objects to arrays for object attributes
- if (is_object($node) && get_class($node) === stdClass::class) {
- $node = $this->convertStdClassToArray($node);
- }
- break;
- default:
- break;
- }
- }
- unset($node);
- $document->setAttribute($key, ($array) ? $value : $value[0]);
- }
-
- if (!$this->getSupportForAttributes()) {
- foreach ($document->getArrayCopy() as $key => $value) {
- // mongodb results out a stdclass for objects
- if (is_object($value) && get_class($value) === stdClass::class) {
- $document->setAttribute($key, $this->convertStdClassToArray($value));
- } elseif ($value instanceof UTCDateTime) {
- $document->setAttribute($key, $this->convertUTCDateToString($value));
- }
- }
- }
- return $document;
- }
-
- private function convertStdClassToArray(mixed $value): mixed
- {
- if (is_object($value) && get_class($value) === stdClass::class) {
- $properties = get_object_vars($value);
-
- return $properties === [] ? $value : array_map($this->convertStdClassToArray(...), $properties);
- }
-
- if (is_array($value)) {
- return array_map(
- fn ($v) => $this->convertStdClassToArray($v),
- $value
- );
- }
-
- return $value;
- }
-
- /**
- * Returns the document after casting to
- * @param Document $collection
- * @param Document $document
- * @return Document
- * @throws Exception
- */
- public function castingBefore(Document $collection, Document $document): Document
- {
- if (!$this->getSupportForInternalCasting()) {
- return $document;
- }
-
- if ($document->isEmpty()) {
- return $document;
- }
-
- $attributes = $collection->getAttribute('attributes', []);
-
- $attributes = \array_merge($attributes, Database::INTERNAL_ATTRIBUTES);
-
- foreach ($attributes as $attribute) {
- $key = $attribute['$id'] ?? '';
- $type = $attribute['type'] ?? '';
- $array = $attribute['array'] ?? false;
-
- $value = $document->getAttribute($key);
- if (is_null($value)) {
- continue;
- }
-
- // Operators are resolved by the database (aggregation pipeline); skip casting
- if (Operator::isOperator($value)) {
- continue;
- }
-
- if ($array) {
- if (is_string($value)) {
- $decoded = json_decode($value, true);
- if (json_last_error() !== JSON_ERROR_NONE) {
- throw new DatabaseException('Failed to decode JSON for attribute ' . $key . ': ' . json_last_error_msg());
- }
- $value = $decoded;
- }
- } else {
- $value = [$value];
- }
-
- foreach ($value as &$node) {
- switch ($type) {
- case Database::VAR_DATETIME:
- if (!($node instanceof UTCDateTime)) {
- try {
- $node = new UTCDateTime(new \DateTime($node));
- } catch (\Throwable $e) {
- throw new StructureException('Invalid datetime value for attribute "' . $key . '": ' . $e->getMessage());
- }
- }
- break;
- case Database::VAR_OBJECT:
- $node = json_decode($node);
- break;
- default:
- break;
- }
- }
- unset($node);
- $document->setAttribute($key, ($array) ? $value : $value[0]);
- }
- $indexes = $collection->getAttribute('indexes');
- $ttlIndexes = array_filter($indexes, fn ($index) => $index->getAttribute('type') === Database::INDEX_TTL);
-
- if (!$this->getSupportForAttributes()) {
- foreach ($document->getArrayCopy() as $key => $value) {
- if (in_array($this->getInternalKeyForAttribute($key), Database::INTERNAL_ATTRIBUTE_KEYS)) {
- continue;
- }
- if (is_string($value) && (in_array($key, $ttlIndexes) || $this->isExtendedISODatetime($value))) {
- try {
- $newValue = new UTCDateTime(new \DateTime($value));
- $document->setAttribute($key, $newValue);
- } catch (\Throwable $th) {
- // skip -> a valid string
- }
- }
- }
- }
-
- return $document;
- }
-
/**
* Create Documents in batches
*
- * @param Document $collection
- * @param array $documents
- *
+ * @param array $documents
* @return array
*
* @throws DuplicateException
@@ -1524,7 +1422,9 @@ public function castingBefore(Document $collection, Document $document): Documen
*/
public function createDocuments(Document $collection, array $documents): array
{
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
+ $this->syncWriteHooks();
+
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
$options = $this->getTransactionOptions();
$records = [];
@@ -1535,15 +1435,18 @@ public function createDocuments(Document $collection, array $documents): array
$sequence = $document->getSequence();
if ($hasSequence === null) {
- $hasSequence = !empty($sequence);
+ $hasSequence = ! empty($sequence);
} elseif ($hasSequence == empty($sequence)) {
throw new DatabaseException('All documents must have an sequence if one is set');
}
- $record = $this->replaceChars('$', '_', (array)$document);
+ /** @var array $documentArr */
+ $documentArr = (array) $document;
+ $record = $this->replaceChars('$', '_', $documentArr);
+ $record = $this->decorateRow($record, $this->documentMetadata($document));
- if (!empty($sequence)) {
- $record['_id'] = $sequence;
+ if (! empty($sequence)) {
+ $record[Storage::SEQUENCE] = $sequence;
}
$records[] = $record;
@@ -1557,14 +1460,14 @@ public function createDocuments(Document $collection, array $documents): array
$operations = [];
foreach ($records as $record) {
- $filter = ['_uid' => $record['_uid'] ?? ''];
+ $filter = [Storage::UID => $record[Storage::UID] ?? ''];
if ($this->sharedTables) {
- $filter['_tenant'] = $record['_tenant'] ?? $this->getTenant();
+ $filter[Storage::TENANT] = $record[Storage::TENANT] ?? $this->getTenant();
}
// Filter fields can't reappear in $setOnInsert (mongo path-conflict error).
$setOnInsert = $record;
- unset($setOnInsert['_uid'], $setOnInsert['_tenant']);
+ unset($setOnInsert[Storage::UID], $setOnInsert[Storage::TENANT]);
if (empty($setOnInsert)) {
continue;
@@ -1592,7 +1495,9 @@ public function createDocuments(Document $collection, array $documents): array
}
foreach ($documents as $index => $document) {
- $documents[$index] = $this->replaceChars('_', '$', $this->client->toArray($document));
+ /** @var array $toArrayResult */
+ $toArrayResult = $this->client->toArray($document) ?? [];
+ $documents[$index] = $this->replaceChars('_', '$', $toArrayResult);
$documents[$index] = new Document($documents[$index]);
}
@@ -1600,69 +1505,25 @@ public function createDocuments(Document $collection, array $documents): array
}
/**
+ * Update Document
*
- * @param string $name
- * @param array $document
- * @param array $options
- *
- * @return array
* @throws DuplicateException
- * @throws Exception
+ * @throws DatabaseException
*/
- private function insertDocument(string $name, array $document, array $options = []): array
+ public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document
{
- try {
- $result = $this->client->insert($name, $document, $options);
- $filters = [];
- $filters['_uid'] = $document['_uid'];
-
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($name);
- }
-
- try {
- $result = $this->client->find(
- $name,
- $filters,
- array_merge(['limit' => 1], $options)
- )->cursor->firstBatch[0];
- } catch (MongoException $e) {
- throw $this->processException($e);
- }
-
- return $this->client->toArray($result);
- } catch (MongoException $e) {
- throw $this->processException($e);
- }
- }
-
- /**
- * Update Document
- *
- * @param Document $collection
- * @param string $id
- * @param Document $document
- * @param bool $skipPermissions
- * @return Document
- * @throws DuplicateException
- * @throws DatabaseException
- */
- public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document
- {
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
$record = $document->getArrayCopy();
$record = $this->replaceChars('$', '_', $record);
- $filters = [];
- $filters['_uid'] = $id;
+ $filters = [Storage::UID => $id];
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection->getId());
- }
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection->getId());
try {
- unset($record['_id']); // Don't update _id
+ unset($record[Storage::SEQUENCE]); // Don't update _id
$options = $this->getTransactionOptions();
@@ -1687,40 +1548,42 @@ public function updateDocument(Document $collection, string $id, Document $docum
*
* Updates all documents which match the given query.
*
- * @param Document $collection
- * @param Document $updates
- * @param array $documents
- *
- * @return int
+ * @param array $documents
*
* @throws DatabaseException
*/
public function updateDocuments(Document $collection, Document $updates, array $documents): int
{
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
$options = $this->getTransactionOptions();
$queries = [
- Query::equal('$sequence', \array_map(fn ($document) => $document->getSequence(), $documents))
+ Query::equal(Document::SEQUENCE, \array_map(fn ($document) => $document->getSequence(), $documents)),
];
+ /** @var array $filters */
$filters = $this->buildFilters($queries);
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection->getId());
- }
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection->getId());
$record = $updates->getArrayCopy();
$record = $this->replaceChars('$', '_', $record);
+ unset($record[Storage::VERSION]);
try {
$pipeline = $this->buildOperatorPipeline($record);
if ($pipeline !== null) {
+ $pipeline[0]['$set'][Storage::VERSION] = [
+ '$add' => [['$ifNull' => ['$' . Storage::VERSION, 0]], 1],
+ ];
+
return $this->updateWithPipeline($name, $filters, $pipeline, $options, multi: true);
}
$updateQuery = [
'$set' => $record,
+ '$inc' => [Storage::VERSION => 1],
];
return $this->client->update(
@@ -1736,72 +1599,60 @@ public function updateDocuments(Document $collection, Document $updates, array $
}
/**
- * Build an aggregation pipeline update from a record that may contain Operator instances.
+ * Build an aggregation pipeline update from a record containing operators.
*
- * Returns null when the record contains no operators, so the caller can fall back to a
- * plain `$set` update. When operators are present, every regular value is wrapped in
- * `$literal` (so it is never interpreted as an aggregation expression) and every operator
- * is translated into the equivalent aggregation expression, all merged into a single
- * `$set` stage.
+ * @param array $record
+ * @return array{0: array{'$set': array}}|null
*
- * @param array $record
- * @return array>|null
* @throws DatabaseException
*/
private function buildOperatorPipeline(array $record): ?array
{
$hasOperators = false;
foreach ($record as $value) {
- if (Operator::isOperator($value)) {
+ if ($value instanceof Operator) {
$hasOperators = true;
+
break;
}
}
- if (!$hasOperators) {
+ if (! $hasOperators) {
return null;
}
$set = [];
foreach ($record as $key => $value) {
- if (Operator::isOperator($value)) {
- $set[$key] = $this->getOperatorExpression($value, $key);
- } else {
- // Wrap literals so values are never parsed as aggregation expressions/field paths
- $set[$key] = ['$literal' => $value];
- }
+ $set[$key] = $value instanceof Operator
+ ? $this->getOperatorExpression($value, $key)
+ : ['$literal' => $value];
}
return [['$set' => $set]];
}
/**
- * Execute an aggregation pipeline update.
- *
- * The Mongo client's update() helper wraps the update document in toObject(), which would
- * turn a pipeline (a list) into an object and break it. We therefore build the raw update
- * command and send it through query(), letting BSON encode the pipeline as an array.
+ * @param array $filters
+ * @param array> $pipeline
+ * @param array $options
*
- * @param string $collection
- * @param array $filters
- * @param array> $pipeline
- * @param array $options
- * @param bool $multi
- * @return int Number of matched documents
* @throws MongoException
*/
- private function updateWithPipeline(string $collection, array $filters, array $pipeline, array $options = [], bool $multi = false): int
- {
+ private function updateWithPipeline(
+ string $collection,
+ array $filters,
+ array $pipeline,
+ array $options = [],
+ bool $multi = false,
+ ): int {
$command = [
'update' => $collection,
- 'updates' => [
- [
- 'q' => $this->client->toObject($filters),
- 'u' => $pipeline,
- 'multi' => $multi,
- 'upsert' => false,
- ],
- ],
+ 'updates' => [[
+ 'q' => $this->client->toObject($filters),
+ 'u' => $pipeline,
+ 'multi' => $multi,
+ 'upsert' => false,
+ ]],
];
if (isset($options['session'])) {
@@ -1814,103 +1665,82 @@ private function updateWithPipeline(string $collection, array $filters, array $p
}
/**
- * Execute a batch of upsert operations, supporting aggregation-pipeline updates.
- *
- * Mirrors the Mongo client's upsert() helper but does not wrap each update in toObject(),
- * so an update may be either a classic update document or an aggregation pipeline (list).
+ * @param array, update: array}> $operations
+ * @param array $options
*
- * @param string $collection
- * @param array, update: array}> $operations
- * @param array $options
- * @return int
* @throws MongoException
*/
private function executeUpsert(string $collection, array $operations, array $options = []): int
{
$updates = [];
- foreach ($operations as $op) {
+ foreach ($operations as $operation) {
$updates[] = [
- 'q' => $this->client->toObject($op['filter']),
- 'u' => $op['update'],
+ 'q' => $this->client->toObject($operation['filter']),
+ 'u' => $operation['update'],
'upsert' => true,
'multi' => false,
];
}
- $command = \array_merge(
- [
- 'update' => $collection,
- 'updates' => $updates,
- ],
- $options
- );
-
- $result = $this->client->query($command);
+ $result = $this->client->query(\array_merge([
+ 'update' => $collection,
+ 'updates' => $updates,
+ ], $options));
return \is_int($result) ? $result : 0;
}
/**
- * Translate an Operator into a MongoDB aggregation expression for use inside a `$set` stage.
- *
- * @param Operator $operator
- * @param string $field The (already escaped) field name the expression is assigned to
- * @return mixed
* @throws DatabaseException
*/
private function getOperatorExpression(Operator $operator, string $field): mixed
{
- $ref = '$' . $field;
+ $reference = '$'.$field;
$method = $operator->getMethod();
$values = $operator->getValues();
switch ($method) {
- // Numeric operators
- case Operator::TYPE_INCREMENT:
- $expr = ['$add' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]];
+ case OperatorType::Increment:
+ $expression = ['$add' => [['$ifNull' => [$reference, 0]], $values[0] ?? 1]];
if (isset($values[1])) {
- $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
+ $expression = ['$cond' => [['$lte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]];
}
- return $expr;
- case Operator::TYPE_DECREMENT:
- $expr = ['$subtract' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]];
+ return $expression;
+
+ case OperatorType::Decrement:
+ $expression = ['$subtract' => [['$ifNull' => [$reference, 0]], $values[0] ?? 1]];
if (isset($values[1])) {
- $expr = ['$cond' => [['$gte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
+ $expression = ['$cond' => [['$gte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]];
}
- return $expr;
- case Operator::TYPE_MULTIPLY:
- $expr = ['$multiply' => [['$ifNull' => [$ref, 0]], $values[0] ?? 1]];
+ return $expression;
+
+ case OperatorType::Multiply:
+ $expression = ['$multiply' => [['$ifNull' => [$reference, 0]], $values[0] ?? 1]];
if (isset($values[1])) {
- $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
+ $expression = ['$cond' => [['$lte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]];
}
- return $expr;
- case Operator::TYPE_DIVIDE:
- $expr = ['$divide' => [['$ifNull' => [$ref, 0]], $values[0]]];
+ return $expression;
+
+ case OperatorType::Divide:
+ $expression = ['$divide' => [['$ifNull' => [$reference, 0]], $values[0]]];
if (isset($values[1])) {
- $expr = ['$cond' => [['$gte' => [$expr, $values[1]]], $expr, ['$ifNull' => [$ref, 0]]]];
+ $expression = ['$cond' => [['$gte' => [$expression, $values[1]]], $expression, ['$ifNull' => [$reference, 0]]]];
}
- return $expr;
- case Operator::TYPE_MODULO:
- return ['$mod' => [['$ifNull' => [$ref, 0]], $values[0]]];
+ return $expression;
- case Operator::TYPE_POWER:
- $base = ['$ifNull' => [$ref, 0]];
- $exponent = $values[0];
- $expr = ['$pow' => [$base, $exponent]];
+ case OperatorType::Modulo:
+ return ['$mod' => [['$ifNull' => [$reference, 0]], $values[0]]];
+
+ case OperatorType::Power:
+ $base = ['$ifNull' => [$reference, 0]];
+ $exponent = $this->getNumericOperand($values, 0, 1, $method);
+ $expression = ['$pow' => [$base, $exponent]];
if (isset($values[1])) {
- // Apply the power only if the result stays within the max; otherwise leave the
- // value unchanged. Overflow yields Infinity, which is greater than the max, so
- // it correctly stays put.
- $expr = ['$cond' => [['$lte' => [$expr, $values[1]]], $expr, $base]];
-
- // Never compute $pow for an undefined input (0 to a negative power, or a
- // negative base to a fractional exponent): it yields NaN, which Mongo orders
- // below every number, so a plain `<= max` check would wrongly apply it. The
- // exponent is constant, so only guard the base condition it can actually trigger.
+ $expression = ['$cond' => [['$lte' => [$expression, $values[1]]], $expression, $base]];
$guards = [];
if ($exponent < 0) {
$guards[] = ['$eq' => [$base, 0]];
@@ -1918,61 +1748,58 @@ private function getOperatorExpression(Operator $operator, string $field): mixed
if (\floor($exponent) != $exponent) {
$guards[] = ['$lt' => [$base, 0]];
}
- if (!empty($guards)) {
+ if (! empty($guards)) {
$undefined = \count($guards) === 1 ? $guards[0] : ['$or' => $guards];
- $expr = ['$cond' => [$undefined, $base, $expr]];
+ $expression = ['$cond' => [$undefined, $base, $expression]];
}
}
- return $expr;
- // String operators
- case Operator::TYPE_STRING_CONCAT:
- return ['$concat' => [['$ifNull' => [$ref, '']], ['$literal' => $values[0] ?? '']]];
+ return $expression;
+
+ case OperatorType::StringConcat:
+ return ['$concat' => [['$ifNull' => [$reference, '']], ['$literal' => $values[0] ?? '']]];
- case Operator::TYPE_STRING_REPLACE:
- // An empty search is a no-op (matches SQL REPLACE semantics); MongoDB's
- // $replaceAll would otherwise insert the replacement between every character.
+ case OperatorType::StringReplace:
if (($values[0] ?? '') === '') {
- return ['$ifNull' => [$ref, '']];
+ return ['$ifNull' => [$reference, '']];
}
+
return ['$replaceAll' => [
- 'input' => ['$ifNull' => [$ref, '']],
+ 'input' => ['$ifNull' => [$reference, '']],
'find' => ['$literal' => $values[0]],
'replacement' => ['$literal' => $values[1] ?? ''],
]];
- // Boolean operators
- case Operator::TYPE_TOGGLE:
- return ['$not' => [['$ifNull' => [$ref, false]]]];
+ case OperatorType::Toggle:
+ return ['$not' => [['$ifNull' => [$reference, false]]]];
- // Array operators
- case Operator::TYPE_ARRAY_APPEND:
- return ['$concatArrays' => [['$ifNull' => [$ref, []]], ['$literal' => \array_values($values)]]];
+ case OperatorType::ArrayAppend:
+ return ['$concatArrays' => [['$ifNull' => [$reference, []]], ['$literal' => \array_values($values)]]];
- case Operator::TYPE_ARRAY_PREPEND:
- return ['$concatArrays' => [['$literal' => \array_values($values)], ['$ifNull' => [$ref, []]]]];
+ case OperatorType::ArrayPrepend:
+ return ['$concatArrays' => [['$literal' => \array_values($values)], ['$ifNull' => [$reference, []]]]];
- case Operator::TYPE_ARRAY_INSERT:
- $index = (int)($values[0] ?? 0);
+ case OperatorType::ArrayInsert:
+ $index = $this->getIntegerOperand($values, 0, 0, $method);
$value = $values[1] ?? null;
- $size = ['$size' => '$$arr'];
- $before = ['$cond' => [['$lte' => [$index, 0]], [], ['$slice' => ['$$arr', $index]]]];
- $after = ['$cond' => [['$gte' => [$index, $size]], [], ['$slice' => ['$$arr', ['$subtract' => [$index, $size]]]]]];
+ $size = ['$size' => '$$array'];
+ $before = ['$cond' => [['$lte' => [$index, 0]], [], ['$slice' => ['$$array', $index]]]];
+ $after = ['$cond' => [['$gte' => [$index, $size]], [], ['$slice' => ['$$array', ['$subtract' => [$index, $size]]]]]];
+
return ['$let' => [
- 'vars' => ['arr' => ['$ifNull' => [$ref, []]]],
+ 'vars' => ['array' => ['$ifNull' => [$reference, []]]],
'in' => ['$concatArrays' => [$before, ['$literal' => [$value]], $after]],
]];
- case Operator::TYPE_ARRAY_REMOVE:
+ case OperatorType::ArrayRemove:
return ['$filter' => [
- 'input' => ['$ifNull' => [$ref, []]],
+ 'input' => ['$ifNull' => [$reference, []]],
'cond' => ['$ne' => ['$$this', ['$literal' => $values[0] ?? null]]],
]];
- case Operator::TYPE_ARRAY_UNIQUE:
- // Preserve first-occurrence order while removing duplicates
+ case OperatorType::ArrayUnique:
return ['$reduce' => [
- 'input' => ['$ifNull' => [$ref, []]],
+ 'input' => ['$ifNull' => [$reference, []]],
'initialValue' => [],
'in' => ['$cond' => [
['$in' => ['$$this', '$$value']],
@@ -1981,54 +1808,89 @@ private function getOperatorExpression(Operator $operator, string $field): mixed
]],
]];
- case Operator::TYPE_ARRAY_INTERSECT:
- // Keep elements present in the given set, preserving original order
+ case OperatorType::ArrayIntersect:
return ['$filter' => [
- 'input' => ['$ifNull' => [$ref, []]],
+ 'input' => ['$ifNull' => [$reference, []]],
'cond' => ['$in' => ['$$this', ['$literal' => \array_values($values)]]],
]];
- case Operator::TYPE_ARRAY_DIFF:
- // Remove elements present in the given set, preserving original order
+ case OperatorType::ArrayDiff:
return ['$filter' => [
- 'input' => ['$ifNull' => [$ref, []]],
+ 'input' => ['$ifNull' => [$reference, []]],
'cond' => ['$not' => [['$in' => ['$$this', ['$literal' => \array_values($values)]]]]],
]];
- case Operator::TYPE_ARRAY_FILTER:
+ case OperatorType::ArrayFilter:
return ['$filter' => [
- 'input' => ['$ifNull' => [$ref, []]],
- 'cond' => $this->getArrayFilterCondition((string)($values[0] ?? ''), $values[1] ?? null),
+ 'input' => ['$ifNull' => [$reference, []]],
+ 'cond' => $this->getArrayFilterCondition($this->getStringOperand($values, 0, '', $method), $values[1] ?? null),
]];
- // Date operators
- case Operator::TYPE_DATE_ADD_DAYS:
+ case OperatorType::DateAddDays:
return ['$dateAdd' => [
- 'startDate' => ['$ifNull' => [$ref, '$$NOW']],
+ 'startDate' => ['$ifNull' => [$reference, '$$NOW']],
'unit' => 'day',
- 'amount' => (int)($values[0] ?? 0),
+ 'amount' => $this->getIntegerOperand($values, 0, 0, $method),
]];
- case Operator::TYPE_DATE_SUB_DAYS:
+ case OperatorType::DateSubDays:
return ['$dateSubtract' => [
- 'startDate' => ['$ifNull' => [$ref, '$$NOW']],
+ 'startDate' => ['$ifNull' => [$reference, '$$NOW']],
'unit' => 'day',
- 'amount' => (int)($values[0] ?? 0),
+ 'amount' => $this->getIntegerOperand($values, 0, 0, $method),
]];
- case Operator::TYPE_DATE_SET_NOW:
+ case OperatorType::DateSetNow:
return '$$NOW';
+ }
+ }
- default:
- throw new DatabaseException("Unsupported operator: {$method}");
+ /**
+ * @param array $values
+ *
+ * @throws DatabaseException
+ */
+ private function getNumericOperand(array $values, int $offset, int|float $default, OperatorType $method): int|float
+ {
+ $value = $values[$offset] ?? $default;
+ if (! \is_int($value) && ! \is_float($value)) {
+ throw new DatabaseException('Invalid numeric operand for operator '.$method->value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param array $values
+ *
+ * @throws DatabaseException
+ */
+ private function getIntegerOperand(array $values, int $offset, int $default, OperatorType $method): int
+ {
+ $value = $values[$offset] ?? $default;
+ if (! \is_int($value)) {
+ throw new DatabaseException('Invalid integer operand for operator '.$method->value);
}
+
+ return $value;
}
/**
- * Build the aggregation condition expression used by the arrayFilter operator.
+ * @param array $values
*
- * @param string $condition
- * @param mixed $compare
+ * @throws DatabaseException
+ */
+ private function getStringOperand(array $values, int $offset, string $default, OperatorType $method): string
+ {
+ $value = $values[$offset] ?? $default;
+ if (! \is_string($value)) {
+ throw new DatabaseException('Invalid string operand for operator '.$method->value);
+ }
+
+ return $value;
+ }
+
+ /**
* @return array
*/
private function getArrayFilterCondition(string $condition, mixed $compare): array
@@ -2044,15 +1906,14 @@ private function getArrayFilterCondition(string $condition, mixed $compare): arr
'lessThanEqual' => ['$lte' => ['$$this', $value]],
'isNull' => ['$eq' => ['$$this', null]],
'isNotNull' => ['$ne' => ['$$this', null]],
- default => ['$literal' => true], // unknown condition keeps every element
+ default => ['$literal' => true],
};
}
/**
- * @param Document $collection
- * @param string $attribute
- * @param array $changes
+ * @param array $changes
* @return array
+ *
* @throws DatabaseException
*/
public function upsertDocuments(Document $collection, string $attribute, array $changes): array
@@ -2061,8 +1922,11 @@ public function upsertDocuments(Document $collection, string $attribute, array $
return $changes;
}
+ $this->syncWriteHooks();
+ $this->syncReadHooks();
+
try {
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
$attribute = $this->filter($attribute);
$operations = [];
@@ -2070,35 +1934,30 @@ public function upsertDocuments(Document $collection, string $attribute, array $
foreach ($changes as $change) {
$document = $change->getNew();
$oldDocument = $change->getOld();
+ /** @var array $attributes */
$attributes = $document->getAttributes();
- $attributes['_uid'] = $document->getId();
- $attributes['_createdAt'] = $document['$createdAt'];
- $attributes['_updatedAt'] = $document['$updatedAt'];
- $attributes['_permissions'] = $document->getPermissions();
-
- if (!empty($document->getSequence())) {
- $attributes['_id'] = $document->getSequence();
- }
+ $attributes[Storage::UID] = $document->getId();
+ $attributes[Storage::CREATED_AT] = $document[Document::CREATED_AT];
+ $attributes[Storage::UPDATED_AT] = $document[Document::UPDATED_AT];
+ $attributes[Storage::PERMISSIONS] = $document->getPermissions();
- if ($this->sharedTables) {
- $attributes['_tenant'] = $document->getTenant();
+ if (! empty($document->getSequence())) {
+ $attributes[Storage::SEQUENCE] = $document->getSequence();
}
$record = $this->replaceChars('$', '_', $attributes);
+ $record = $this->decorateRow($record, $this->documentMetadata($document));
// Build filter for upsert
- $filters = ['_uid' => $document->getId()];
+ $filters = [Storage::UID => $document->getId()];
+ $filters = $this->applyReadFilters($filters, $collection->getId());
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection->getId());
- }
-
- unset($record['_id']); // Don't update _id
+ unset($record[Storage::SEQUENCE]); // Don't update _id
// Get fields to unset for schemaless mode
$unsetFields = $this->getUpsertAttributeRemovals($oldDocument, $document, $record);
- if (!empty($attribute)) {
+ if (! empty($attribute)) {
// Get the attribute value before removing it from $set
$attributeValue = $record[$attribute] ?? 0;
@@ -2112,46 +1971,37 @@ public function upsertDocuments(Document $collection, string $attribute, array $
// Increment the specific attribute and update all other fields
$update = [
'$inc' => [$attribute => $attributeValue],
- '$set' => $record
+ '$set' => $record,
];
- if (!empty($unsetFields)) {
+ if (! empty($unsetFields)) {
$update['$unset'] = $unsetFields;
}
} else {
$pipeline = $this->buildOperatorPipeline($record);
-
if ($pipeline !== null) {
- // Operator-based upsert: resolve operators via an aggregation pipeline
- // so they apply atomically, with $ifNull defaults on insert.
$set = $pipeline[0]['$set'];
-
- // Generate an _id only on insert; keep the existing one on update.
if (empty($document->getSequence())) {
- $set['_id'] = ['$ifNull' => ['$_id', $this->client->createUuid()]];
+ $set[Storage::SEQUENCE] = ['$ifNull' => ['$' . Storage::SEQUENCE, $this->client->createUuid()]];
}
$update = [['$set' => $set]];
-
- if (!empty($unsetFields)) {
+ if (! empty($unsetFields)) {
$update[] = ['$unset' => \array_keys($unsetFields)];
}
-
$hasPipeline = true;
} else {
- // Update all fields
$update = [
- '$set' => $record
+ '$set' => $record,
];
- if (!empty($unsetFields)) {
+ if (! empty($unsetFields)) {
$update['$unset'] = $unsetFields;
}
- // Add UUID7 _id for new documents in upsert operations
if (empty($document->getSequence())) {
$update['$setOnInsert'] = [
- '_id' => $this->client->createUuid()
+ Storage::SEQUENCE => $this->client->createUuid(),
];
}
}
@@ -2166,8 +2016,6 @@ public function upsertDocuments(Document $collection, string $attribute, array $
$options = $this->getTransactionOptions();
if ($hasPipeline) {
- // The client's upsert() wraps each update in toObject(), which would corrupt a
- // pipeline (a list). Send the raw command so BSON encodes pipelines as arrays.
$this->executeUpsert($name, $operations, $options);
} else {
$this->client->upsert(
@@ -2184,167 +2032,103 @@ public function upsertDocuments(Document $collection, string $attribute, array $
}
/**
- * Get fields to unset for schemaless upsert operations
+ * Delete Document
*
- * @param Document $oldDocument
- * @param Document $newDocument
- * @param array $record
- * @return array
+ *
+ * @throws Exception
*/
- private function getUpsertAttributeRemovals(Document $oldDocument, Document $newDocument, array $record): array
+ public function deleteDocument(string $collection, string $id): bool
{
- $unsetFields = [];
-
- if ($this->getSupportForAttributes() || $oldDocument->isEmpty()) {
- return $unsetFields;
- }
-
- $oldUserAttributes = $oldDocument->getAttributes();
- $newUserAttributes = $newDocument->getAttributes();
-
- $protectedFields = ['_uid', '_id', '_createdAt', '_updatedAt', '_permissions', '_tenant'];
+ $name = $this->getNamespace().'_'.$this->filter($collection);
- foreach ($oldUserAttributes as $originalKey => $originalValue) {
- if (in_array($originalKey, $protectedFields) || array_key_exists($originalKey, $newUserAttributes)) {
- continue;
- }
+ $filters = [Storage::UID => $id];
- $transformed = $this->replaceChars('$', '_', [$originalKey => $originalValue]);
- $dbKey = array_key_first($transformed);
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection);
- if ($dbKey && !array_key_exists($dbKey, $record) && !in_array($dbKey, $protectedFields)) {
- $unsetFields[$dbKey] = '';
- }
- }
+ $options = $this->getTransactionOptions();
+ $result = $this->client->delete($name, $filters, 1, [], $options);
- return $unsetFields;
+ return (bool) $result;
}
/**
- * Get sequences for documents that were created
+ * Delete Documents
+ *
+ * @param array $sequences
+ * @param array $permissionIds
*
- * @param string $collection
- * @param array $documents
- * @return array
* @throws DatabaseException
- * @throws MongoException
*/
- public function getSequences(string $collection, array $documents): array
+ public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int
{
- $documentIds = [];
- $documentTenants = [];
- foreach ($documents as $document) {
- if (empty($document->getSequence())) {
- $documentIds[] = $document->getId();
-
- if ($this->sharedTables) {
- $documentTenants[] = $document->getTenant();
- }
- }
- }
-
- if (empty($documentIds)) {
- return $documents;
- }
-
- $sequences = [];
- $name = $this->getNamespace() . '_' . $this->filter($collection);
-
- $filters = ['_uid' => ['$in' => $documentIds]];
+ $name = $this->getNamespace().'_'.$this->filter($collection);
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection, $documentTenants);
+ foreach ($sequences as $index => $sequence) {
+ $sequences[$index] = $sequence;
}
- try {
- // Use cursor paging for large result sets
- $options = [
- 'projection' => ['_uid' => 1, '_id' => 1],
- 'batchSize' => self::DEFAULT_BATCH_SIZE
- ];
-
- $options = $this->getTransactionOptions($options);
- $response = $this->client->find($name, $filters, $options);
- $results = $response->cursor->firstBatch ?? [];
-
- // Process first batch
- foreach ($results as $result) {
- $sequences[$result->_uid] = (string)$result->_id;
- }
- // Get cursor ID for subsequent batches
- $cursorId = $response->cursor->id ?? null;
+ /** @var array $filters */
+ $filters = $this->buildFilters([new Query(Method::Equal, Storage::SEQUENCE, $sequences)]);
- // Continue fetching with getMore
- while ($cursorId && $cursorId !== 0) {
- $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE);
- $moreResults = $moreResponse->cursor->nextBatch ?? [];
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection);
- if (empty($moreResults)) {
- break;
- }
+ $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators);
- foreach ($moreResults as $result) {
- $sequences[$result->_uid] = (string)$result->_id;
- }
+ $options = $this->getTransactionOptions();
- // Update cursor ID for next iteration
- $cursorId = (int)($moreResponse->cursor->id ?? 0);
- }
+ try {
+ return $this->client->delete(
+ collection: $name,
+ filters: $filters,
+ limit: 0,
+ options: $options
+ );
} catch (MongoException $e) {
throw $this->processException($e);
}
-
- foreach ($documents as $document) {
- if (isset($sequences[$document->getId()])) {
- $document['$sequence'] = $sequences[$document->getId()];
- }
- }
-
- return $documents;
}
/**
* Increase or decrease an attribute value
*
- * @param string $collection
- * @param string $id
- * @param string $attribute
- * @param int|float $value
- * @param string $updatedAt
- * @param int|float|null $min
- * @param int|float|null $max
- * @return bool
* @throws DatabaseException
* @throws MongoException
* @throws Exception
*/
- public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, string $updatedAt, int|float|null $min = null, int|float|null $max = null): bool
+ public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool
{
+ $value = $this->normalizeAtomicNumber($value, 'value');
+ $min = $min === null ? null : $this->normalizeAtomicNumber($min, 'minimum');
+ $max = $max === null ? null : $this->normalizeAtomicNumber($max, 'maximum');
+
$attribute = $this->filter($attribute);
- $filters = ['_uid' => $id];
+ $filters = [Storage::UID => $id];
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection);
- }
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection);
if ($max !== null || $min !== null) {
- $filters[$attribute] = [];
+ /** @var array $attributeFilter */
+ $attributeFilter = [];
if ($max !== null) {
- $filters[$attribute]['$lte'] = $max;
+ $attributeFilter['$lte'] = $max;
}
if ($min !== null) {
- $filters[$attribute]['$gte'] = $min;
+ $attributeFilter['$gte'] = $min;
}
+ $filters[$attribute] = $attributeFilter;
}
$options = $this->getTransactionOptions();
try {
$this->client->update(
- $this->getNamespace() . '_' . $this->filter($collection),
+ $this->getNamespace().'_'.$this->filter($collection),
$filters,
[
'$inc' => [$attribute => $value],
- '$set' => ['_updatedAt' => $this->toMongoDatetime($updatedAt)],
+ '$set' => [Storage::UPDATED_AT => $this->toMongoDatetime($updatedAt)],
],
options: $options
);
@@ -2355,211 +2139,98 @@ public function increaseDocumentAttribute(string $collection, string $id, string
return true;
}
+ private function normalizeAtomicNumber(int|float|string $value, string $name): int|float
+ {
+ if (! \is_string($value)) {
+ return $value;
+ }
+ if (! BigInt::fitsPhpInt($value)) {
+ throw new TypeException("MongoDB cannot safely apply {$name} outside the signed 64-bit integer range.");
+ }
+
+ return (int) $value;
+ }
+
/**
- * Delete Document
+ * Find Documents
*
- * @param string $collection
- * @param string $id
+ * Find data sets using chosen queries
+ *
+ * @param array $queries
+ * @param array $orderAttributes
+ * @param array $orderTypes
+ * @param array $cursor
+ * @return array
*
- * @return bool
* @throws Exception
+ * @throws TimeoutException
*/
- public function deleteDocument(string $collection, string $id): bool
+ public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array
{
- $name = $this->getNamespace() . '_' . $this->filter($collection);
-
- $filters = [];
- $filters['_uid'] = $id;
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
+ $queries = array_map(fn ($query) => clone $query, $queries);
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection);
- }
+ // Escape query attribute names that contain dots and match collection attributes
+ // (to distinguish from nested object paths like profile.level1.value)
+ $this->escapeQueryAttributes($collection, $queries);
- $options = $this->getTransactionOptions();
- $result = $this->client->delete($name, $filters, 1, [], $options);
+ /** @var array $filters */
+ $filters = $this->buildFilters($queries);
- return (!!$result);
- }
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection->getId(), $forPermission->value);
- /**
- * Delete Documents
- *
- * @param string $collection
- * @param array $sequences
- * @param array $permissionIds
- * @return int
- * @throws DatabaseException
- */
- public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int
- {
- $name = $this->getNamespace() . '_' . $this->filter($collection);
+ $options = [];
- foreach ($sequences as $index => $sequence) {
- $sequences[$index] = $sequence;
+ if (! \is_null($limit)) {
+ $options['limit'] = $limit;
+ }
+ if (! \is_null($offset)) {
+ $options['skip'] = $offset;
}
- $filters = $this->buildFilters([new Query(Query::TYPE_EQUAL, '_id', $sequences)]);
+ if ($this->timeout) {
+ $options['maxTimeMS'] = $this->timeout;
+ }
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection);
+ $selections = $this->getAttributeSelections($queries);
+ $hasProjection = ! empty($selections) && ! \in_array('*', $selections);
+ if ($hasProjection) {
+ $options['projection'] = $this->getAttributeProjection($selections);
}
- $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators);
+ // Add transaction context to options
+ $options = $this->getTransactionOptions($options);
- $options = $this->getTransactionOptions();
-
- try {
- return $this->client->delete(
- collection: $name,
- filters: $filters,
- limit: 0,
- options: $options
- );
- } catch (MongoException $e) {
- throw $this->processException($e);
- }
- }
-
- /**
- * Update Attribute.
- * @param string $collection
- * @param string $id
- * @param string $type
- * @param int $size
- * @param bool $signed
- * @param bool $array
- * @param string $newKey
- *
- * @return bool
- */
- public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool
- {
- if (!empty($newKey) && $newKey !== $id) {
- return $this->renameAttribute($collection, $id, $newKey);
- }
- return true;
- }
-
- /**
- * TODO Consider moving this to adapter.php
- * @param string $attribute
- * @return string
- */
- protected function getInternalKeyForAttribute(string $attribute): string
- {
- return match ($attribute) {
- '$id' => '_uid',
- '$sequence' => '_id',
- '$collection' => '_collection',
- '$tenant' => '_tenant',
- '$createdAt' => '_createdAt',
- '$updatedAt' => '_updatedAt',
- '$deletedAt' => '_deletedAt',
- '$permissions' => '_permissions',
- default => $attribute
- };
- }
-
- /**
- * @return list
- */
- private function permissionStrings(string $type): array
- {
- $permissions = [];
- foreach ($this->authorization->getRoles() as $role) {
- $permissions[] = $type . '("' . $role . '")';
- }
-
- return $permissions;
- }
-
- /**
- * Find Documents
- *
- * Find data sets using chosen queries
- *
- * @param Document $collection
- * @param array $queries
- * @param int|null $limit
- * @param int|null $offset
- * @param array $orderAttributes
- * @param array $orderTypes
- * @param array $cursor
- * @param string $cursorDirection
- * @param string $forPermission
- *
- * @return array
- * @throws Exception
- * @throws TimeoutException
- */
- public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array
- {
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
- $queries = array_map(fn ($query) => clone $query, $queries);
-
- // Escape query attribute names that contain dots and match collection attributes
- // (to distinguish from nested object paths like profile.level1.value)
- $this->escapeQueryAttributes($collection, $queries);
-
- $filters = $this->buildFilters($queries);
-
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection->getId());
- }
-
- // permissions
- if ($this->authorization->getStatus()) {
- $filters['_permissions']['$in'] = $this->permissionStrings($forPermission);
- }
-
- $options = [];
-
- if (!\is_null($limit)) {
- $options['limit'] = $limit;
- }
- if (!\is_null($offset)) {
- $options['skip'] = $offset;
- }
-
- if ($this->timeout) {
- $options['maxTimeMS'] = $this->timeout;
- }
-
- $selections = $this->getAttributeSelections($queries);
- $hasProjection = !empty($selections) && !\in_array('*', $selections);
- if ($hasProjection) {
- $options['projection'] = $this->getAttributeProjection($selections);
- }
-
- // Add transaction context to options
- $options = $this->getTransactionOptions($options);
-
- $orFilters = [];
+ $orFilters = [];
+ /** @var array $sortOptions */
+ $sortOptions = [];
foreach ($orderAttributes as $i => $originalAttribute) {
$attribute = $this->getInternalKeyForAttribute($originalAttribute);
$attribute = $this->filter($attribute);
- $orderType = $this->filter($orderTypes[$i] ?? Database::ORDER_ASC);
+ $orderType = $orderTypes[$i] ?? OrderDirection::Asc;
$direction = $orderType;
/** Get sort direction ASC || DESC **/
- if ($cursorDirection === Database::CURSOR_BEFORE) {
- $direction = ($direction === Database::ORDER_ASC)
- ? Database::ORDER_DESC
- : Database::ORDER_ASC;
+ if ($cursorDirection === CursorDirection::Before) {
+ $direction = ($direction === OrderDirection::Asc)
+ ? OrderDirection::Desc
+ : OrderDirection::Asc;
}
- $options['sort'][$attribute] = $this->getOrder($direction);
+ $sortOptions[$attribute] = $this->getOrder($direction);
+ $options['sort'] = $sortOptions;
/** Get operator sign '$lt' ? '$gt' **/
- $operator = $cursorDirection === Database::CURSOR_AFTER
- ? ($orderType === Database::ORDER_DESC ? Query::TYPE_LESSER : Query::TYPE_GREATER)
- : ($orderType === Database::ORDER_DESC ? Query::TYPE_GREATER : Query::TYPE_LESSER);
+ $operator = $cursorDirection === CursorDirection::After
+ ? ($orderType === OrderDirection::Desc ? Method::LessThan : Method::GreaterThan)
+ : ($orderType === OrderDirection::Desc ? Method::GreaterThan : Method::LessThan);
$operator = $this->getQueryOperator($operator);
- if (!empty($cursor)) {
+ if (! empty($cursor)) {
$andConditions = [];
for ($j = 0; $j < $i; $j++) {
@@ -2567,17 +2238,17 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
$prevAttr = $this->filter($this->getInternalKeyForAttribute($originalPrev));
$tmp = $cursor[$originalPrev];
$andConditions[] = [
- $prevAttr => $tmp
+ $prevAttr => $tmp,
];
}
$tmp = $cursor[$originalAttribute];
- if ($originalAttribute === '$sequence') {
+ if ($originalAttribute === Document::SEQUENCE) {
/** If there is only $sequence attribute in $orderAttributes skip Or And operators **/
if (count($orderAttributes) === 1) {
$filters[$attribute] = [
- $operator => $tmp
+ $operator => $tmp,
];
break;
}
@@ -2585,24 +2256,26 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
$andConditions[] = [
$attribute => [
- $operator => $tmp
- ]
+ $operator => $tmp,
+ ],
];
$orFilters[] = [
- '$and' => $andConditions
+ '$and' => $andConditions,
];
}
}
- if (!empty($orFilters)) {
+ if (! empty($orFilters)) {
$filters['$or'] = $orFilters;
}
// Translate operators and handle time filters
+ /** @var array $filters */
$filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators);
$found = [];
+ /** @var int|null $cursorId */
$cursorId = null;
try {
@@ -2610,31 +2283,63 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
$options['batchSize'] = self::DEFAULT_BATCH_SIZE;
$response = $this->client->find($name, $filters, $options);
- $results = $response->cursor->firstBatch ?? [];
+ /** @var \stdClass $responseCursorFind */
+ $responseCursorFind = $response->cursor;
+ /** @var array $results */
+ $results = $responseCursorFind->firstBatch ?? [];
// Process first batch
foreach ($results as $result) {
- $record = $this->replaceChars('_', '$', (array)$result);
- $found[] = new Document($this->convertStdClassToArray($record));
+ /** @var array $resultCast */
+ $resultCast = (array) $result;
+ $record = $this->replaceChars('_', '$', $resultCast);
+ /** @var array $convertedRecord */
+ $convertedRecord = $this->convertStdClassToArray($record);
+ $found[] = new Document($convertedRecord);
}
// Get cursor ID for subsequent batches
- $cursorId = $response->cursor->id ?? null;
+ if (isset($responseCursorFind->id)) {
+ /** @var mixed $responseCursorFindId */
+ $responseCursorFindId = $responseCursorFind->id;
+ $cursorId = \is_int($responseCursorFindId) ? $responseCursorFindId : (\is_scalar($responseCursorFindId) ? (int) $responseCursorFindId : null);
+ if ($cursorId === 0) {
+ $cursorId = null;
+ }
+ } else {
+ $cursorId = null;
+ }
// Continue fetching with getMore
- while ($cursorId && $cursorId !== 0) {
- $moreResponse = $this->client->getMore((int)$cursorId, $name, self::DEFAULT_BATCH_SIZE);
- $moreResults = $moreResponse->cursor->nextBatch ?? [];
+ while ($cursorId !== null) {
+ $moreResponse = $this->client->getMore($cursorId, $name, self::DEFAULT_BATCH_SIZE);
+ /** @var \stdClass $moreCursorFind */
+ $moreCursorFind = $moreResponse->cursor;
+ /** @var array $moreResults */
+ $moreResults = $moreCursorFind->nextBatch ?? [];
if (empty($moreResults)) {
break;
}
foreach ($moreResults as $result) {
- $record = $this->replaceChars('_', '$', (array)$result);
- $found[] = new Document($this->convertStdClassToArray($record));
+ /** @var array $resultCast */
+ $resultCast = (array) $result;
+ $record = $this->replaceChars('_', '$', $resultCast);
+ /** @var array $convertedRecord */
+ $convertedRecord = $this->convertStdClassToArray($record);
+ $found[] = new Document($convertedRecord);
}
- $cursorId = (int)($moreResponse->cursor->id ?? 0);
+ if (isset($moreCursorFind->id)) {
+ /** @var mixed $moreCursorFindId */
+ $moreCursorFindId = $moreCursorFind->id;
+ $cursorId = \is_int($moreCursorFindId) ? $moreCursorFindId : (\is_scalar($moreCursorFindId) ? (int) $moreCursorFindId : null);
+ if ($cursorId === 0) {
+ $cursorId = null;
+ }
+ } else {
+ $cursorId = null;
+ }
}
} catch (MongoException $e) {
throw $this->processException($e);
@@ -2644,20 +2349,20 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
try {
$this->client->query([
'killCursors' => $name,
- 'cursors' => [(int)$cursorId]
+ 'cursors' => [$cursorId],
]);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// Ignore errors during cursor cleanup
}
}
}
- if ($cursorDirection === Database::CURSOR_BEFORE) {
+ if ($cursorDirection === CursorDirection::Before) {
$found = array_reverse($found);
}
// Ensure missing relationship attributes are set to null (MongoDB doesn't store null fields)
- if (!$hasProjection) {
+ if (! $hasProjection) {
foreach ($found as $document) {
$this->ensureRelationshipDefaults($collection, $document);
}
@@ -2666,84 +2371,16 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
return $found;
}
-
- /**
- * Converts Appwrite database type to MongoDB BSON type code.
- *
- * @param string $appwriteType
- * @return string
- */
- private function getMongoTypeCode(string $appwriteType): string
- {
- return match ($appwriteType) {
- Database::VAR_STRING => 'string',
- Database::VAR_VARCHAR => 'string',
- Database::VAR_TEXT => 'string',
- Database::VAR_MEDIUMTEXT => 'string',
- Database::VAR_LONGTEXT => 'string',
- Database::VAR_INTEGER => 'int',
- Database::VAR_BIGINT => 'long',
- Database::VAR_FLOAT => 'double',
- Database::VAR_BOOLEAN => 'bool',
- Database::VAR_DATETIME => 'date',
- Database::VAR_ID => 'string',
- Database::VAR_UUID7 => 'string',
- default => 'string'
- };
- }
-
- /**
- * Converts timestamp to Mongo\BSON datetime format.
- *
- * @param string $dt
- * @return UTCDateTime
- * @throws Exception
- */
- private function toMongoDatetime(string $dt): UTCDateTime
- {
- return new UTCDateTime(new \DateTime($dt));
- }
-
- /**
- * Recursive function to replace chars in array keys, while
- * skipping any that are explicitly excluded.
- *
- * @param array $array
- * @param string $from
- * @param string $to
- * @param array $exclude
- * @return array
- */
- private function replaceInternalIdsKeys(array $array, string $from, string $to, array $exclude = []): array
- {
- $result = [];
-
- foreach ($array as $key => $value) {
- if (!in_array($key, $exclude)) {
- $key = str_replace($from, $to, $key);
- }
-
- $result[$key] = is_array($value)
- ? $this->replaceInternalIdsKeys($value, $from, $to, $exclude)
- : $value;
- }
-
- return $result;
- }
-
-
/**
* Count Documents
*
- * @param Document $collection
- * @param array $queries
- * @param int|null $max
- * @return int
+ * @param array $queries
+ *
* @throws Exception
*/
public function count(Document $collection, array $queries = [], ?int $max = null): int
{
- $name = $this->getNamespace() . '_' . $this->filter($collection->getId());
+ $name = $this->getNamespace().'_'.$this->filter($collection->getId());
$queries = array_map(fn ($query) => clone $query, $queries);
@@ -2751,19 +2388,23 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
$this->escapeQueryAttributes($collection, $queries);
$filters = [];
+ $options = [];
- // Build filters from queries
- $filters = $this->buildFilters($queries);
-
- if ($this->sharedTables) {
- $filters['_tenant'] = $this->getTenantFilters($collection->getId());
+ if (! \is_null($max) && $max > 0) {
+ $options['limit'] = $max;
}
- // Add permissions filter if authorization is enabled
- if ($this->authorization->getStatus()) {
- $filters['_permissions']['$in'] = $this->permissionStrings(Database::PERMISSION_READ);
+ if ($this->timeout) {
+ $options['maxTimeMS'] = $this->timeout;
}
+ // Build filters from queries
+ /** @var array $filters */
+ $filters = $this->buildFilters($queries);
+
+ $this->syncReadHooks();
+ $filters = $this->applyReadFilters($filters, $collection->getId());
+
/**
* Use MongoDB aggregation pipeline for accurate counting
* Accuracy and Sharded Clusters
@@ -2772,7 +2413,6 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
* To avoid these situations, on a sharded cluster, use the db.collection.aggregate() method"
* https://www.mongodb.com/docs/manual/reference/command/count/#response
**/
-
$options = $this->getTransactionOptions();
if ($this->timeout) {
@@ -2782,29 +2422,29 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
$pipeline = [];
// Add match stage if filters are provided
- if (!empty($filters)) {
+ if (! empty($filters)) {
$pipeline[] = ['$match' => $this->client->toObject($filters)];
}
// Add limit stage if specified
- if (!\is_null($max) && $max > 0) {
+ if (! \is_null($max) && $max > 0) {
$pipeline[] = ['$limit' => $max];
}
// Use $group and $sum when limit is specified, $count when no limit
// Note: $count stage doesn't works well with $limit in the same pipeline
// When limit is specified, we need to use $group + $sum to count the limited documents
- if (!\is_null($max) && $max > 0) {
+ if (! \is_null($max) && $max > 0) {
// When limit is specified, use $group and $sum to count limited documents
$pipeline[] = [
'$group' => [
- '_id' => null,
- 'total' => ['$sum' => 1]]
+ Storage::SEQUENCE => null,
+ 'total' => ['$sum' => 1]],
];
} else {
// When no limit is passed, use $count for better performance
$pipeline[] = [
- '$count' => 'total'
+ '$count' => 'total',
];
}
@@ -2813,12 +2453,21 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
$result = $this->client->aggregate($name, $pipeline, $options);
// Aggregation returns stdClass with cursor property containing firstBatch
- if (isset($result->cursor) && !empty($result->cursor->firstBatch)) {
- $firstResult = $result->cursor->firstBatch[0];
-
- // Handle both $count and $group response formats
- if (isset($firstResult->total)) {
- return (int)$firstResult->total;
+ if (isset($result->cursor)) {
+ /** @var \stdClass $aggCursor */
+ $aggCursor = $result->cursor;
+ if (! empty($aggCursor->firstBatch)) {
+ /** @var array $aggFirstBatch */
+ $aggFirstBatch = $aggCursor->firstBatch;
+ /** @var \stdClass $firstResult */
+ $firstResult = $aggFirstBatch[0];
+
+ // Handle both $count and $group response formats
+ if (isset($firstResult->total)) {
+ /** @var mixed $totalVal */
+ $totalVal = $firstResult->total;
+ return \is_int($totalVal) ? $totalVal : (\is_numeric($totalVal) ? (int) $totalVal : 0);
+ }
}
}
@@ -2833,35 +2482,24 @@ public function count(Document $collection, array $queries = [], ?int $max = nul
}
}
-
/**
* Sum an attribute
*
- * @param Document $collection
- * @param string $attribute
- * @param array $queries
- * @param int|null $max
+ * @param array