diff --git a/bin/cli.php b/bin/cli.php index d9932d0b2..65a0638c0 100644 --- a/bin/cli.php +++ b/bin/cli.php @@ -3,6 +3,7 @@ require_once '/usr/src/code/vendor/autoload.php'; use Utopia\CLI\CLI; +use Utopia\CLI\Console; ini_set('memory_limit', '-1'); @@ -11,5 +12,13 @@ include 'tasks/load.php'; include 'tasks/index.php'; include 'tasks/query.php'; +include 'tasks/relationships.php'; + +$cli + ->error() + ->inject('error') + ->action(function ($error) { + Console::error($error->getMessage()); + }); $cli->run(); diff --git a/bin/relationships b/bin/relationships new file mode 100755 index 000000000..e0c7cec44 --- /dev/null +++ b/bin/relationships @@ -0,0 +1,3 @@ +#!/bin/sh + +php /usr/src/code/bin/cli.php relationships "$@" diff --git a/bin/tasks/index.php b/bin/tasks/index.php index d96284fe2..195fbd565 100644 --- a/bin/tasks/index.php +++ b/bin/tasks/index.php @@ -10,105 +10,103 @@ use Utopia\CLI\CLI; use Utopia\CLI\Console; use Utopia\Database\Adapter\MariaDB; -use Utopia\Database\Adapter\Mongo; use Utopia\Database\Adapter\MySQL; +use Utopia\Database\Adapter\Postgres; use Utopia\Database\Database; use Utopia\Database\PDO; -use Utopia\Mongo\Client; +use Utopia\Validator\Boolean; use Utopia\Validator\Text; /** * @Example * docker compose exec tests bin/index --adapter=mysql --name=testing */ - $cli ->task('index') ->desc('Index mock data for testing queries') ->param('adapter', '', new Text(0), 'Database adapter') ->param('name', '', new Text(0), 'Name of created database.') - ->action(function ($adapter, $name) { + ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) + ->action(function (string $adapter, string $name, bool $sharedTables) { $namespace = '_ns'; $cache = new Cache(new NoCache()); - switch ($adapter) { - case 'mongodb': - $client = new Client( - $name, - 'mongo', - 27017, - 'root', - 'example', - false - ); - - $database = new Database(new Mongo($client), $cache); - break; - - case 'mariadb': - $dbHost = 'mariadb'; - $dbPort = '3306'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); - - $database = new Database(new MariaDB($pdo), $cache); - break; - - case 'mysql': - $dbHost = 'mysql'; - $dbPort = '3307'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); + $dbAdapters = [ + 'mariadb' => [ + 'host' => 'mariadb', + 'port' => 3306, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'adapter' => MariaDB::class, + 'pdoAttr' => MariaDB::getPDOAttributes(), + ], + 'mysql' => [ + 'host' => 'mysql', + 'port' => 3307, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'adapter' => MySQL::class, + 'pdoAttr' => MySQL::getPDOAttributes(), + ], + 'postgres' => [ + 'host' => 'postgres', + 'port' => 5432, + 'user' => 'postgres', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", + 'adapter' => Postgres::class, + 'pdoAttr' => Postgres::getPDOAttributes(), + ], + ]; + + if (!isset($dbAdapters[$adapter])) { + Console::error("Adapter '{$adapter}' not supported"); + return; + } - $database = new Database(new MySQL($pdo), $cache); - break; + $cfg = $dbAdapters[$adapter]; - default: - Console::error('Adapter not supported'); - return; - } + $pdo = new PDO( + ($cfg['dsn'])($cfg['host'], $cfg['port']), + $cfg['user'], + $cfg['pass'], + $cfg['pdoAttr'] + ); - $database->setDatabase($name); - $database->setNamespace($namespace); + $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables); - Console::info("greaterThan('created', ['2010-01-01 05:00:00']), equal('genre', ['travel'])"); + 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]); $time = microtime(true) - $start; - Console::success("{$time} seconds"); + Console::success("Index 'createdGenre' created in {$time} seconds"); - Console::info("equal('genre', ['fashion', 'finance', 'sports'])"); + 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]); $time = microtime(true) - $start; - Console::success("{$time} seconds"); + Console::success("Index 'genre' created in {$time} seconds"); - Console::info("greaterThan('views', 100000)"); + 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]); $time = microtime(true) - $start; - Console::success("{$time} seconds"); + Console::success("Index 'views' created in {$time} seconds"); - Console::info("search('text', 'Alice')"); + Console::info("Creating fulltext index 'fulltextsearch' on 'articles' for search term 'Alice'"); $start = microtime(true); $database->createIndex('articles', 'fulltextsearch', Database::INDEX_FULLTEXT, ['text']); $time = microtime(true) - $start; - Console::success("{$time} seconds"); + Console::success("Index 'fulltextsearch' created in {$time} seconds"); - Console::info("contains('tags', ['tag1'])"); + Console::info("Creating key index 'tags' on 'articles' for tags containing 'tag1'"); $start = microtime(true); $database->createIndex('articles', 'tags', Database::INDEX_KEY, ['tags']); $time = microtime(true) - $start; - Console::success("{$time} seconds"); - }); - -$cli - ->error() - ->inject('error') - ->action(function (Exception $error) { - Console::error($error->getMessage()); + Console::success("Index 'tags' created in {$time} seconds"); }); diff --git a/bin/tasks/load.php b/bin/tasks/load.php index fef039388..0a5c99719 100644 --- a/bin/tasks/load.php +++ b/bin/tasks/load.php @@ -1,232 +1,143 @@ task('load') ->desc('Load database with mock data for testing') - ->param('adapter', '', new Text(0), 'Database adapter', false) - ->param('limit', '', new Numeric(), 'Total number of records to add to database', false) - ->param('name', 'myapp_'.uniqid(), new Text(0), 'Name of created database.', true) - ->action(function ($adapter, $limit, $name) { + ->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('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) + ->action(function (string $adapter, int $limit, string $name, bool $sharedTables) { $start = null; $namespace = '_ns'; $cache = new Cache(new NoCache()); Console::info("Filling {$adapter} with {$limit} records: {$name}"); - Swoole\Runtime::enableCoroutine(); - - switch ($adapter) { - case 'mariadb': - Co\run(function () use (&$start, $limit, $name, $namespace, $cache) { - // can't use PDO pool to act above the database level e.g. creating schemas - $dbHost = 'mariadb'; - $dbPort = '3306'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); - - $database = new Database(new MariaDB($pdo), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - - // Outline collection schema - createSchema($database); - - // reclaim resources - $database = null; - $pdo = null; - - // Init Faker - $faker = Factory::create(); - - $start = microtime(true); - - // create PDO pool for coroutines - $pool = new PDOPool( - (new PDOConfig()) - ->withHost('mariadb') - ->withPort(3306) - ->withDbName($name) - ->withCharset('utf8mb4') - ->withUsername('root') - ->withPassword('password'), - 128 - ); - - // A coroutine is assigned per 1000 documents - for ($i = 0; $i < $limit / 1000; $i++) { - \go(function () use ($pool, $faker, $name, $cache, $namespace) { - $pdo = $pool->get(); - - $database = new Database(new MariaDB($pdo), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - - // Each coroutine loads 1000 documents - for ($i = 0; $i < 1000; $i++) { - createDocument($database, $faker); - } - - // Reclaim resources - $pool->put($pdo); - $database = null; - }); - } - }); - break; - - case 'mysql': - Co\run(function () use (&$start, $limit, $name, $namespace, $cache) { - // can't use PDO pool to act above the database level e.g. creating schemas - $dbHost = 'mysql'; - $dbPort = '3307'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); - - $database = new Database(new MySQL($pdo), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - - // Outline collection schema - createSchema($database); - - // reclaim resources - $database = null; - $pdo = null; - - // Init Faker - $faker = Factory::create(); - - $start = microtime(true); - - // create PDO pool for coroutines - $pool = new PDOPool( - (new PDOConfig()) - ->withHost('mysql') - ->withPort(3307) - // ->withUnixSocket('/tmp/mysql.sock') - ->withDbName($name) - ->withCharset('utf8mb4') - ->withUsername('root') - ->withPassword('password'), - 128 - ); - - // A coroutine is assigned per 1000 documents - for ($i = 0; $i < $limit / 1000; $i++) { - \go(function () use ($pool, $faker, $name, $cache, $namespace) { - $pdo = $pool->get(); - - $database = new Database(new MySQL($pdo), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - - // Each coroutine loads 1000 documents - for ($i = 0; $i < 1000; $i++) { - createDocument($database, $faker); - } - - // Reclaim resources - $pool->put($pdo); - $database = null; - }); - } - }); - break; - - case 'mongodb': - Co\run(function () use (&$start, $limit, $name, $namespace, $cache) { - $client = new Client( - $name, - 'mongo', - 27017, - 'root', - 'example', - false - ); - - $database = new Database(new Mongo($client), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - - // Outline collection schema - createSchema($database); - - // Fill DB - $faker = Factory::create(); - - $start = microtime(true); - - for ($i = 0; $i < $limit / 1000; $i++) { - go(function () use ($client, $faker, $name, $namespace, $cache) { - $database = new Database(new Mongo($client), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - - // Each coroutine loads 1000 documents - for ($i = 0; $i < 1000; $i++) { - createDocument($database, $faker); - } - - $database = null; - }); - } - }); - break; + //Runtime::enableCoroutine(); + + $dbAdapters = [ + 'mariadb' => [ + 'host' => 'mariadb', + 'port' => 3306, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'driver' => 'mysql', + 'adapter' => MariaDB::class, + 'attrs' => MariaDB::getPDOAttributes(), + ], + 'mysql' => [ + 'host' => 'mysql', + 'port' => 3307, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'driver' => 'mysql', + 'adapter' => MySQL::class, + 'attrs' => MySQL::getPDOAttributes(), + ], + 'postgres' => [ + 'host' => 'postgres', + 'port' => 5432, + 'user' => 'postgres', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", + 'driver' => 'pgsql', + 'adapter' => Postgres::class, + 'attrs' => Postgres::getPDOAttributes(), + ], + ]; + + if (!isset($dbAdapters[$adapter])) { + Console::error("Adapter '{$adapter}' not supported"); + return; + } - default: - echo '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) { + $pdo = new PDO( + $dsn, + $cfg['user'], + $cfg['pass'], + $cfg['attrs'] + ); + + createSchema( + (new Database(new ($cfg['adapter'])($pdo), $cache)) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables) + ); + + $pool = new PDOPool( + (new PDOConfig()) + ->withDriver($cfg['driver']) + ->withHost($cfg['host']) + ->withPort($cfg['port']) + ->withDbName($name) + //->withCharset('utf8mb4') + ->withUsername($cfg['user']) + ->withPassword($cfg['pass']), + 128 + ); + + $start = \microtime(true); + + for ($i = 0; $i < $limit / 1000; $i++) { + //\go(function () use ($cfg, $pool, $name, $namespace, $sharedTables, $cache) { + try { + //$pdo = $pool->get(); + + $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables); + + createDocuments($database); + //$pool->put($pdo); + } catch (\Throwable $error) { + Console::error('Coroutine error: ' . $error->getMessage()); + } + //}); } $time = microtime(true) - $start; Console::success("Completed in {$time} seconds"); }); - - -$cli - ->error() - ->inject('error') - ->action(function (Exception $error) { - Console::error($error->getMessage()); - }); - - function createSchema(Database $database): void { if ($database->exists($database->getDatabase())) { @@ -247,35 +158,43 @@ function createSchema(Database $database): void $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']); } -function createDocument($database, Generator $faker): void +function createDocuments(Database $database): void { - $database->createDocument('articles', new Document([ - // Five random users out of 10,000 get read access - // Three random users out of 10,000 get mutate access - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user($faker->randomNumber(9))), - Permission::read(Role::user($faker->randomNumber(9))), - Permission::read(Role::user($faker->randomNumber(9))), - Permission::read(Role::user($faker->randomNumber(9))), - Permission::create(Role::user($faker->randomNumber(9))), - Permission::create(Role::user($faker->randomNumber(9))), - Permission::create(Role::user($faker->randomNumber(9))), - Permission::update(Role::user($faker->randomNumber(9))), - Permission::update(Role::user($faker->randomNumber(9))), - Permission::update(Role::user($faker->randomNumber(9))), - Permission::delete(Role::user($faker->randomNumber(9))), - Permission::delete(Role::user($faker->randomNumber(9))), - Permission::delete(Role::user($faker->randomNumber(9))), - ], - 'author' => $faker->name(), - 'created' => \Utopia\Database\DateTime::format($faker->dateTime()), - 'text' => $faker->realTextBetween(1000, 4000), - 'genre' => $faker->randomElement(['fashion', 'food', 'travel', 'music', 'lifestyle', 'fitness', 'diy', 'sports', 'finance']), - 'views' => $faker->randomNumber(6), - 'tags' => $faker->randomElements(['short', 'quick', 'easy', 'medium', 'hard'], $faker->numberBetween(1, 5)), - ])); + global $namesPool, $genresPool, $tagsPool; + + $documents = []; + + $start = \microtime(true); + for ($i = 0; $i < 1000; $i++) { + $length = \mt_rand(1000, 4000); + $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); + $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)), + ], + 'author' => $namesPool[\array_rand($namesPool)], + 'created' => DateTime::now(), + 'text' => $text, + 'genre' => $genresPool[\array_rand($genresPool)], + 'views' => \mt_rand(0, 999999), + 'tags' => $tags, + ]); + } + $time = \microtime(true) - $start; + Console::info("Prepared 1000 documents in {$time} seconds"); + $start = \microtime(true); + $database->createDocuments('articles', $documents, 1000); + $time = \microtime(true) - $start; + Console::success("Inserted 1000 documents in {$time} seconds"); } diff --git a/bin/tasks/query.php b/bin/tasks/query.php index ed84fd00c..3a8f8b613 100644 --- a/bin/tasks/query.php +++ b/bin/tasks/query.php @@ -1,8 +1,9 @@ desc('Query mock data') ->param('adapter', '', new Text(0), 'Database adapter') ->param('name', '', new Text(0), 'Name of created database.') - ->param('limit', 25, new Numeric(), 'Limit on queried documents', true) - ->action(function (string $adapter, string $name, int $limit) { + ->param('limit', 25, new Integer(true), 'Limit on queried documents', true) + ->param('sharedTables', false, new Boolean(true), 'Whether to use shared tables', true) + ->action(function (string $adapter, string $name, int $limit, bool $sharedTables) { $namespace = '_ns'; $cache = new Cache(new NoCache()); - switch ($adapter) { - case 'mongodb': - $client = new Client( - $name, - 'mongo', - 27017, - 'root', - 'example', - false - ); - - $database = new Database(new Mongo($client), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - break; - - case 'mariadb': - $dbHost = 'mariadb'; - $dbPort = '3306'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); - - $database = new Database(new MariaDB($pdo), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - break; - - case 'mysql': - $dbHost = 'mysql'; - $dbPort = '3307'; - $dbUser = 'root'; - $dbPass = 'password'; - - $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); - - $database = new Database(new MySQL($pdo), $cache); - $database->setDatabase($name); - $database->setNamespace($namespace); - break; - - default: - Console::error('Adapter not supported'); - return; + // ------------------------------------------------------------------ + // Adapter configuration + // ------------------------------------------------------------------ + $dbAdapters = [ + 'mariadb' => [ + 'host' => 'mariadb', + 'port' => 3306, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'adapter' => MariaDB::class, + 'pdoAttr' => MariaDB::getPDOAttributes(), + ], + 'mysql' => [ + 'host' => 'mysql', + 'port' => 3307, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'adapter' => MySQL::class, + 'pdoAttr' => MySQL::getPDOAttributes(), + ], + 'postgres' => [ + 'host' => 'postgres', + 'port' => 5432, + 'user' => 'postgres', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", + 'adapter' => Postgres::class, + 'pdoAttr' => Postgres::getPDOAttributes(), + ], + ]; + + if (!isset($dbAdapters[$adapter])) { + Console::error("Adapter '{$adapter}' not supported"); + return; } + $cfg = $dbAdapters[$adapter]; + + $pdo = new PDO( + ($cfg['dsn'])($cfg['host'], $cfg['port']), + $cfg['user'], + $cfg['pass'], + $cfg['pdoAttr'] + ); + + $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables); + $faker = Factory::create(); $report = []; $count = setRoles($faker, 1); - Console::info("\n{$count} roles:"); + Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; $count = setRoles($faker, 100); - Console::info("\n{$count} roles:"); + Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; $count = setRoles($faker, 400); - Console::info("\n{$count} roles:"); + Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; $count = setRoles($faker, 500); - Console::info("\n{$count} roles:"); + Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; $count = setRoles($faker, 1000); - Console::info("\n{$count} roles:"); + Console::info("\nRunning queries with {$count} authorization roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) @@ -129,13 +136,6 @@ \fclose($results); }); -$cli - ->error() - ->inject('error') - ->action(function (Exception $error) { - Console::error($error->getMessage()); - }); - function setRoles($faker, $count): int { for ($i = 0; $i < $count; $i++) { @@ -188,10 +188,10 @@ function runQuery(array $query, Database $database) return $q->getAttribute() . ': ' . $q->getMethod() . ' = ' . implode(',', $q->getValues()); }, $query); - Console::log('Running query: [' . implode(', ', $info) . ']'); + Console::info("Running query: [" . implode(', ', $info) . "]"); $start = microtime(true); $database->find('articles', $query); $time = microtime(true) - $start; - Console::success("{$time} s"); + Console::success("Query executed in {$time} seconds"); return $time; } diff --git a/bin/tasks/relationships.php b/bin/tasks/relationships.php new file mode 100644 index 000000000..595d01531 --- /dev/null +++ b/bin/tasks/relationships.php @@ -0,0 +1,555 @@ +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('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) { + $start = null; + $namespace = '_ns'; + $cache = new Cache(new NoCache()); + + Console::info("Filling {$adapter} with {$limit} records: {$name}"); + + $dbAdapters = [ + 'mariadb' => [ + 'host' => 'mariadb', + 'port' => 3306, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'adapter' => MariaDB::class, + 'attrs' => MariaDB::getPDOAttributes(), + ], + 'mysql' => [ + 'host' => 'mysql', + 'port' => 3307, + 'user' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "mysql:host={$host};port={$port};charset=utf8mb4", + 'adapter' => MySQL::class, + 'attrs' => MySQL::getPDOAttributes(), + ], + 'postgres' => [ + 'host' => 'postgres', + 'port' => 5432, + 'user' => 'postgres', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", + 'adapter' => Postgres::class, + 'attrs' => Postgres::getPDOAttributes(), + ], + ]; + + if (!isset($dbAdapters[$adapter])) { + Console::error("Adapter '{$adapter}' not supported"); + return; + } + + $cfg = $dbAdapters[$adapter]; + + $pdo = new PDO( + ($cfg['dsn'])($cfg['host'], $cfg['port']), + $cfg['user'], + $cfg['pass'], + $cfg['attrs'] + ); + + $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables); + + createRelationshipSchema($database); + + // Create categories and users once before parallel batch creation + $globalDocs = createGlobalDocuments($database, $limit); + + $pdo = null; + + $pool = new PDOPool( + (new PDOConfig()) + ->withHost($cfg['host']) + ->withPort($cfg['port']) + ->withDbName($name) + ->withCharset('utf8mb4') + ->withUsername($cfg['user']) + ->withPassword($cfg['pass']), + size: 64 + ); + + $start = \microtime(true); + + for ($i = 0; $i < $limit / 1000; $i++) { + go(function () use ($cfg, $pool, $name, $namespace, $sharedTables, $cache, $globalDocs) { + try { + $pdo = $pool->get(); + + $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + ->setDatabase($name) + ->setNamespace($namespace) + ->setSharedTables($sharedTables); + + createRelationshipDocuments($database, $globalDocs['categories'], $globalDocs['users']); + $pool->put($pdo); + } catch (\Throwable $error) { + // Errors caught but documents still created successfully - likely concurrent update race conditions + } + }); + } + + $time = microtime(true) - $start; + Console::success("Document creation completed in {$time} seconds"); + + // Display relationship structure + displayRelationshipStructure(); + + // Collect benchmark results across runs + $results = []; + + Console::info("Running benchmarks {$runs} time(s)..."); + + for ($run = 1; $run <= $runs; $run++) { + if ($runs > 1) { + Console::info("Run {$run}/{$runs}"); + } + + $results[] = [ + 'single' => benchmarkSingle($database), + 'batch100' => benchmarkBatch100($database), + 'batch1000' => benchmarkBatch1000($database), + 'batch5000' => benchmarkBatch5000($database), + 'pagination' => benchmarkPagination($database), + ]; + } + + // Calculate and display averages + displayBenchmarkResults($results, $runs); + }); + +function createRelationshipSchema(Database $database): void +{ + if ($database->exists($database->getDatabase())) { + $database->delete($database->getDatabase()); + } + $database->create(); + + Authorization::setRole(Role::any()->toString()); + + $database->createCollection('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: [ + 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: [ + 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->createCollection('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->createCollection('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->createCollection('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); +} + +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))); + $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, + ]); + } + + // Create categories once - documents are modified in place with IDs + $database->createDocuments('categories', $categoryDocs); + + // Scale users based on limit (10% of total documents) + $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', + 'password' => \bin2hex(\random_bytes(8)), + ]); + } + + // Create users once + $database->createDocuments('users', $userDocs); + + // Return both categories and users + return ['categories' => $categoryDocs, 'users' => $userDocs]; +} + +function createRelationshipDocuments(Database $database, array $categories, array $users): void +{ + global $namesPool, $genresPool, $tagsPool; + + $documents = []; + $start = \microtime(true); + + // Prepare pools for nested data + $numAuthors = 10; + $numArticlesPerAuthor = 10; + $numCommentsPerArticle = 10; + + // Generate authors with nested articles and comments + for ($a = 0; $a < $numAuthors; $a++) { + $author = new Document([ + '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, + ]); + + // 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, + ], + 'verified' => (bool)\mt_rand(0, 1), + ]); + $author->setAttribute('profiles', $profile); + + // Nested articles + $authorArticles = []; + for ($i = 0; $i < $numArticlesPerAuthor; $i++) { + $article = new Document([ + '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), + 'tags' => \array_slice($tagsPool, 0, \mt_rand(1, \count($tagsPool))), + 'category' => $categories[\array_rand($categories)], + ]); + + // Nested comments + $comments = []; + for ($c = 0; $c < $numCommentsPerArticle; $c++) { + $comment = new Document([ + 'content' => 'Comment ' . ($c + 1), + 'likes' => \mt_rand(0, 10000), + 'user' => $users[\array_rand($users)], + ]); + $comments[] = $comment; + } + + $article->setAttribute('comments', $comments); + $authorArticles[] = $article; + } + + $author->setAttribute('articles', $authorArticles); + $documents[] = $author; + } + + // Insert authors (with nested articles, comments, and users) + $start = \microtime(true); + $database->createDocuments('authors', $documents); + $time = \microtime(true) - $start; + Console::success("Inserted nested documents in {$time} seconds"); +} + +/** + * Benchmark querying a single document from each collection. + */ +function benchmarkSingle(Database $database): array +{ + $collections = ['authors', 'articles', 'users', 'comments', 'profiles', 'categories']; + $results = []; + + foreach ($collections as $collection) { + // Fetch one document ID to use (skip relationships to avoid infinite recursion) + $docs = $database->skipRelationships(fn () => $database->findOne($collection)); + $id = $docs->getId(); + + $start = microtime(true); + $database->getDocument($collection, $id); + $time = microtime(true) - $start; + + $results[$collection] = $time; + } + + return $results; +} + +/** + * Benchmark querying 100 documents from each collection. + */ +function benchmarkBatch100(Database $database): array +{ + $collections = ['authors', 'articles', 'users', 'comments', 'profiles', 'categories']; + $results = []; + + foreach ($collections as $collection) { + $start = microtime(true); + $database->find($collection, [Query::limit(100)]); + $time = microtime(true) - $start; + + $results[$collection] = $time; + } + + return $results; +} + +/** + * Benchmark querying 1000 documents from each collection. + */ +function benchmarkBatch1000(Database $database): array +{ + $collections = ['authors', 'articles', 'users', 'comments', 'profiles', 'categories']; + $results = []; + + foreach ($collections as $collection) { + $start = microtime(true); + $database->find($collection, [Query::limit(1000)]); + $time = microtime(true) - $start; + + $results[$collection] = $time; + } + + return $results; +} + +/** + * Benchmark querying 5000 documents from each collection. + */ +function benchmarkBatch5000(Database $database): array +{ + $collections = ['authors', 'articles', 'users', 'comments', 'profiles', 'categories']; + $results = []; + + foreach ($collections as $collection) { + $start = microtime(true); + $database->find($collection, [Query::limit(5000)]); + $time = microtime(true) - $start; + + $results[$collection] = $time; + } + + return $results; +} + +/** + * Benchmark cursor pagination through entire collection in chunks of 100. + */ +function benchmarkPagination(Database $database): array +{ + $collections = ['authors', 'articles', 'users', 'comments', 'profiles', 'categories']; + $results = []; + + foreach ($collections as $collection) { + $total = 0; + $limit = 100; + $cursor = null; + $start = microtime(true); + do { + $queries = [Query::limit($limit)]; + if ($cursor !== null) { + $queries[] = Query::cursorAfter($cursor); + } + $docs = $database->find($collection, $queries); + $count = count($docs); + $total += $count; + if ($count > 0) { + $cursor = $docs[$count - 1]; + } + } while ($count === $limit); + $time = microtime(true) - $start; + + $results[$collection] = $time; + } + + return $results; +} + +/** + * Display relationship structure diagram + */ +function displayRelationshipStructure(): void +{ + Console::success("\n========================================"); + 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(""); +} + +/** + * Display benchmark results as a formatted table + */ +function displayBenchmarkResults(array $results, int $runs): void +{ + $collections = ['authors', 'articles', 'users', 'comments', 'profiles', 'categories']; + $benchmarks = ['single', 'batch100', 'batch1000', 'batch5000', 'pagination']; + $benchmarkLabels = [ + 'single' => 'Single Query', + 'batch100' => 'Batch 100', + 'batch1000' => 'Batch 1000', + 'batch5000' => 'Batch 5000', + 'pagination' => 'Pagination', + ]; + + // Calculate averages + $averages = []; + foreach ($benchmarks as $benchmark) { + $averages[$benchmark] = []; + foreach ($collections as $collection) { + $total = 0; + foreach ($results as $run) { + $total += $run[$benchmark][$collection] ?? 0; + } + $averages[$benchmark][$collection] = $total / $runs; + } + } + + Console::success("\n========================================"); + Console::success("Benchmark Results (Average of {$runs} run" . ($runs > 1 ? 's' : '') . ")"); + Console::success("========================================\n"); + + // Calculate column widths + $collectionWidth = 12; + $timeWidth = 12; + + // Print header + $header = str_pad('Collection', $collectionWidth) . ' | '; + foreach ($benchmarkLabels as $label) { + $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) . ' | '; + foreach ($benchmarks as $benchmark) { + $time = number_format($averages[$benchmark][$collection] * 1000, 2); // Convert to ms + $row .= str_pad($time . ' ms', $timeWidth) . ' | '; + } + Console::log($row); + } + + Console::log(''); +} diff --git a/bin/view/index.php b/bin/view/index.php index 55a2b7a97..4afb1e677 100644 --- a/bin/view/index.php +++ b/bin/view/index.php @@ -3,33 +3,27 @@ - utopia-php/database - -
-
- +
- - - - - - + + @@ -42,20 +36,21 @@ diff --git a/src/Database/Database.php b/src/Database/Database.php index c516130f6..abe854c20 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -86,6 +86,7 @@ class Database public const RELATION_SIDE_CHILD = 'child'; public const RELATION_MAX_DEPTH = 3; + public const RELATION_QUERY_CHUNK_SIZE = 5000; // Orders public const ORDER_ASC = 'ASC'; @@ -321,11 +322,6 @@ class Database protected string $cacheName = 'default'; - /** - * @var array - */ - protected array $map = []; - /** * @var array */ @@ -358,7 +354,9 @@ class Database protected bool $checkRelationshipsExist = true; - protected int $relationshipFetchDepth = 1; + protected int $relationshipFetchDepth = 0; + + protected bool $inBatchRelationshipPopulation = false; protected bool $filter = true; @@ -371,7 +369,7 @@ class Database protected bool $preserveDates = false; - protected int $maxQueryValues = 100; + protected int $maxQueryValues = 5000; protected bool $migrating = false; @@ -3540,10 +3538,11 @@ public function getDocument(string $collection, string $id, array $queries = [], $document = $this->casting($collection, $document); $document = $this->decode($collection, $document, $selections); - $this->map = []; - if ($this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { - $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document, $nestedSelections)); + // Skip relationship population if we're in batch mode (relationships will be populated later) + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth, $nestedSelections)); + $document = $documents[0]; } $relationships = \array_filter( @@ -3567,262 +3566,599 @@ public function getDocument(string $collection, string $id, array $queries = [], } /** + * Populate relationships for an array of documents with breadth-first traversal + * + * @param array $documents * @param Document $collection - * @param Document $document + * @param int $relationshipFetchDepth * @param array> $selects - * @return Document + * @return array * @throws DatabaseException */ - private function populateDocumentRelationships(Document $collection, Document $document, array $selects = []): Document - { - $attributes = $collection->getAttribute('attributes', []); + private function populateDocumentsRelationships( + array $documents, + Document $collection, + int $relationshipFetchDepth = 0, + array $selects = [] + ): array { + // Prevent nested relationship population during fetches + $this->inBatchRelationshipPopulation = true; - $relationships = []; + try { + $queue = [ + [ + 'documents' => $documents, + 'collection' => $collection, + 'depth' => $relationshipFetchDepth, + 'selects' => $selects, + 'skipKey' => null, // No back-reference to skip at top level + 'hasExplicitSelects' => !empty($selects) // Track if we're in explicit select mode + ] + ]; - foreach ($attributes as $attribute) { - if ($attribute['type'] === Database::VAR_RELATIONSHIP) { - if (empty($selects) || array_key_exists($attribute['key'], $selects)) { - $relationships[] = $attribute; + $currentDepth = $relationshipFetchDepth; + + while (!empty($queue) && $currentDepth < self::RELATION_MAX_DEPTH) { + $nextQueue = []; + + foreach ($queue as $item) { + $docs = $item['documents']; + $coll = $item['collection']; + $sels = $item['selects']; + $skipKey = $item['skipKey'] ?? null; + $parentHasExplicitSelects = $item['hasExplicitSelects']; + + if (empty($docs)) { + continue; + } + + $attributes = $coll->getAttribute('attributes', []); + $relationships = []; + + foreach ($attributes as $attribute) { + if ($attribute['type'] === Database::VAR_RELATIONSHIP) { + // Skip the back-reference relationship that brought us here + if ($attribute['key'] === $skipKey) { + continue; + } + + // Include relationship if: + // 1. No explicit selects (fetch all) OR + // 2. Relationship is explicitly selected + if (!$parentHasExplicitSelects || \array_key_exists($attribute['key'], $sels)) { + $relationships[] = $attribute; + } + } + } + + foreach ($relationships as $relationship) { + $key = $relationship['key']; + $queries = $sels[$key] ?? []; + $relationship->setAttribute('collection', $coll->getId()); + $isAtMaxDepth = ($currentDepth + 1) >= self::RELATION_MAX_DEPTH; + + // If we're at max depth, remove this relationship from source documents and skip + if ($isAtMaxDepth) { + foreach ($docs as $doc) { + $doc->removeAttribute($key); + } + continue; + } + + $relatedDocs = $this->populateSingleRelationshipBatch( + $docs, + $relationship, + $queries + ); + + // Get two-way relationship info + $twoWay = $relationship['options']['twoWay']; + $twoWayKey = $relationship['options']['twoWayKey']; + + // Queue if: + // 1. No explicit selects (fetch all recursively), OR + // 2. Explicit nested selects for this relationship + $hasNestedSelectsForThisRel = isset($sels[$key]); + $shouldQueue = !empty($relatedDocs) && + ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); + + if ($shouldQueue) { + $relatedCollectionId = $relationship['options']['relatedCollection']; + $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollectionId)); + + if (!$relatedCollection->isEmpty()) { + // Get nested selections for this relationship + $relationshipQueries = $hasNestedSelectsForThisRel ? $sels[$key] : []; + + // Extract nested selections for the related collection + $relatedCollectionRelationships = $relatedCollection->getAttribute('attributes', []); + $relatedCollectionRelationships = \array_filter( + $relatedCollectionRelationships, + fn ($attr) => $attr['type'] === Database::VAR_RELATIONSHIP + ); + + $nextSelects = $this->processRelationshipQueries($relatedCollectionRelationships, $relationshipQueries); + + // If parent has explicit selects, child inherits that mode + // (even if nextSelects is empty, we're still in explicit mode) + $childHasExplicitSelects = $parentHasExplicitSelects; + + $nextQueue[] = [ + 'documents' => $relatedDocs, + 'collection' => $relatedCollection, + 'depth' => $currentDepth + 1, + 'selects' => $nextSelects, + 'skipKey' => $twoWay ? $twoWayKey : null, // Skip the back-reference at next depth + 'hasExplicitSelects' => $childHasExplicitSelects + ]; + } + } + + // Remove back-references for two-way relationships + // Back-references are always removed to prevent circular references + if ($twoWay && !empty($relatedDocs)) { + foreach ($relatedDocs as $relatedDoc) { + $relatedDoc->removeAttribute($twoWayKey); + } + } + } } + + $queue = $nextQueue; + $currentDepth++; } + } finally { + $this->inBatchRelationshipPopulation = false; } - foreach ($relationships as $relationship) { - $key = $relationship['key']; + return $documents; + } + + /** + * Populate a single relationship type for all documents in batch + * Returns all related documents that were populated + * + * @param array $documents + * @param Document $relationship + * @param array $queries + * @return array + * @throws DatabaseException + */ + private function populateSingleRelationshipBatch( + array $documents, + Document $relationship, + array $queries + ): array { + return match ($relationship['options']['relationType']) { + Database::RELATION_ONE_TO_ONE => $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_ONE_TO_MANY => $this->populateOneToManyRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_MANY_TO_ONE => $this->populateManyToOneRelationshipsBatch($documents, $relationship, $queries), + Database::RELATION_MANY_TO_MANY => $this->populateManyToManyRelationshipsBatch($documents, $relationship, $queries), + default => [], + }; + } + + /** + * Populate one-to-one relationships in batch + * Returns all related documents that were fetched + * + * @param array $documents + * @param Document $relationship + * @param array $queries + * @return array + * @throws DatabaseException + */ + private function populateOneToOneRelationshipsBatch(array $documents, Document $relationship, array $queries): array + { + $key = $relationship['key']; + $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); + + $relatedIds = []; + $documentsByRelatedId = []; + + foreach ($documents as $document) { $value = $document->getAttribute($key); - $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - $relationType = $relationship['options']['relationType']; - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; - $side = $relationship['options']['side']; + if (!\is_null($value)) { + // Skip if value is already populated + if ($value instanceof Document) { + continue; + } - // Clone queries to avoid mutation affecting subsequent documents - $queries = array_map(fn ($query) => clone $query, $selects[$key] ?? []); + // For one-to-one, multiple documents can reference the same related ID + $relatedIds[] = $value; + if (!isset($documentsByRelatedId[$value])) { + $documentsByRelatedId[$value] = []; + } + $documentsByRelatedId[$value][] = $document; + } + } - if (!empty($value)) { - $k = $relatedCollection->getId() . ':' . $value . '=>' . $collection->getId() . ':' . $document->getId(); - if ($relationType === Database::RELATION_ONE_TO_MANY) { - $k = $collection->getId() . ':' . $document->getId() . '=>' . $relatedCollection->getId() . ':' . $value; + if (empty($relatedIds)) { + return []; + } + + $uniqueRelatedIds = \array_unique($relatedIds); + $relatedDocuments = []; + + // Process in chunks to avoid exceeding query value limits + foreach (\array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + $chunkDocs = $this->find($relatedCollection->getId(), [ + Query::equal('$id', $chunk), + Query::limit(PHP_INT_MAX), + ...$queries + ]); + \array_push($relatedDocuments, ...$chunkDocs); + } + + // Index related documents by ID for quick lookup + $relatedById = []; + foreach ($relatedDocuments as $related) { + $relatedById[$related->getId()] = $related; + } + + // Assign related documents to their parent documents + foreach ($documentsByRelatedId as $relatedId => $docs) { + if (isset($relatedById[$relatedId])) { + // Set the relationship for all documents that reference this related ID + foreach ($docs as $document) { + $document->setAttribute($key, $relatedById[$relatedId]); + } + } else { + // If related document not found, set to empty Document instead of leaving the string ID + foreach ($docs as $document) { + $document->setAttribute($key, new Document()); } - $this->map[$k] = true; } + } - $relationship->setAttribute('collection', $collection->getId()); - $relationship->setAttribute('document', $document->getId()); + return $relatedDocuments; + } - $skipFetch = false; - foreach ($this->relationshipFetchStack as $fetchedRelationship) { - $existingKey = $fetchedRelationship['key']; - $existingCollection = $fetchedRelationship['collection']; - $existingRelatedCollection = $fetchedRelationship['options']['relatedCollection']; - $existingTwoWayKey = $fetchedRelationship['options']['twoWayKey']; - $existingSide = $fetchedRelationship['options']['side']; - - // If this relationship has already been fetched for this document, skip it - $reflexive = $fetchedRelationship == $relationship; - - // If this relationship is the same as a previously fetched relationship, but on the other side, skip it - $symmetric = $existingKey === $twoWayKey - && $existingTwoWayKey === $key - && $existingRelatedCollection === $collection->getId() - && $existingCollection === $relatedCollection->getId() - && $existingSide !== $side; - - // If this relationship is not directly related but relates across multiple collections, skip it. - // - // These conditions ensure that a relationship is considered transitive if it has the same - // two-way key and related collection, but is on the opposite side of the relationship (the first and second conditions). - // - // They also ensure that a relationship is considered transitive if it has the same key and related - // collection as an existing relationship, but a different two-way key (the third condition), - // or the same two-way key as an existing relationship, but a different key (the fourth condition). - $transitive = (($existingKey === $twoWayKey - && $existingCollection === $relatedCollection->getId() - && $existingSide !== $side) - || ($existingTwoWayKey === $key - && $existingRelatedCollection === $collection->getId() - && $existingSide !== $side) - || ($existingKey === $key - && $existingTwoWayKey !== $twoWayKey - && $existingRelatedCollection === $relatedCollection->getId() - && $existingSide !== $side) - || ($existingKey !== $key - && $existingTwoWayKey === $twoWayKey - && $existingRelatedCollection === $relatedCollection->getId() - && $existingSide !== $side)); - - if ($reflexive || $symmetric || $transitive) { - $skipFetch = true; - } - } - - switch ($relationType) { - case Database::RELATION_ONE_TO_ONE: - if ($skipFetch || $twoWay && ($this->relationshipFetchDepth === Database::RELATION_MAX_DEPTH)) { - $document->removeAttribute($key); - break; - } + /** + * Populate one-to-many relationships in batch + * Returns all related documents that were fetched + * + * @param array $documents + * @param Document $relationship + * @param array $queries + * @return array + * @throws DatabaseException + */ + private function populateOneToManyRelationshipsBatch( + array $documents, + Document $relationship, + array $queries, + ): array { + $key = $relationship['key']; + $twoWay = $relationship['options']['twoWay']; + $twoWayKey = $relationship['options']['twoWayKey']; + $side = $relationship['options']['side']; + $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - if (\is_null($value)) { - break; - } + if ($side === Database::RELATION_SIDE_CHILD) { + // Child side - treat like one-to-one + if (!$twoWay) { + foreach ($documents as $document) { + $document->removeAttribute($key); + } + return []; + } + return $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries); + } - $this->relationshipFetchDepth++; - $this->relationshipFetchStack[] = $relationship; + // Parent side - fetch multiple related documents + $parentIds = []; + foreach ($documents as $document) { + $parentId = $document->getId(); + $parentIds[] = $parentId; + } - $related = $this->getDocument($relatedCollection->getId(), $value, $queries); + $parentIds = \array_unique($parentIds); - $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchStack); + if (empty($parentIds)) { + return []; + } - $document->setAttribute($key, $related); - break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_CHILD) { - if (!$twoWay || $this->relationshipFetchDepth === Database::RELATION_MAX_DEPTH || $skipFetch) { - $document->removeAttribute($key); - break; - } - if (!\is_null($value)) { - $this->relationshipFetchDepth++; - $this->relationshipFetchStack[] = $relationship; + // For batch relationship population, we need to fetch documents with all fields + // to enable proper grouping by back-reference, then apply selects afterward + $selectQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_SELECT) { + $selectQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } - $related = $this->getDocument($relatedCollection->getId(), $value, $queries); + $relatedDocuments = []; - $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchStack); + foreach (\array_chunk($parentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + $chunkDocs = $this->find($relatedCollection->getId(), [ + Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX), + ...$otherQueries + ]); + \array_push($relatedDocuments, ...$chunkDocs); + } - $document->setAttribute($key, $related); - } - break; - } + // Group related documents by parent ID + $relatedByParentId = []; + foreach ($relatedDocuments as $related) { + $parentId = $related->getAttribute($twoWayKey); + if (!\is_null($parentId)) { + // Handle case where parentId might be a Document object instead of string + $parentKey = $parentId instanceof Document + ? $parentId->getId() + : $parentId; - if ($this->relationshipFetchDepth === Database::RELATION_MAX_DEPTH || $skipFetch) { - break; - } + if (!isset($relatedByParentId[$parentKey])) { + $relatedByParentId[$parentKey] = []; + } + // We don't remove the back-reference here because documents may be reused across fetches + // Cycles are prevented by depth limiting in breadth-first traversal + $relatedByParentId[$parentKey][] = $related; + } + } - $this->relationshipFetchDepth++; - $this->relationshipFetchStack[] = $relationship; + $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); - $relatedDocuments = $this->find($relatedCollection->getId(), [ - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX), - ...$queries - ]); + // Assign related documents to their parent documents + foreach ($documents as $document) { + $parentId = $document->getId(); + $relatedDocs = $relatedByParentId[$parentId] ?? []; + $document->setAttribute($key, $relatedDocs); + } - $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchStack); + return $relatedDocuments; + } - foreach ($relatedDocuments as $related) { - $related->removeAttribute($twoWayKey); - } + /** + * Populate many-to-one relationships in batch + * + * @param array $documents + * @param Document $relationship + * @param array $queries + * @return array + * @throws DatabaseException + */ + private function populateManyToOneRelationshipsBatch( + array $documents, + Document $relationship, + array $queries, + ): array { + $key = $relationship['key']; + $twoWay = $relationship['options']['twoWay']; + $twoWayKey = $relationship['options']['twoWayKey']; + $side = $relationship['options']['side']; + $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); - $document->setAttribute($key, $relatedDocuments); - break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { - if ($skipFetch || $this->relationshipFetchDepth === Database::RELATION_MAX_DEPTH) { - $document->removeAttribute($key); - break; - } + if ($side === Database::RELATION_SIDE_PARENT) { + // Parent side - treat like one-to-one + return $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries); + } - if (\is_null($value)) { - break; - } - $this->relationshipFetchDepth++; - $this->relationshipFetchStack[] = $relationship; + // Child side - fetch multiple related documents + if (!$twoWay) { + foreach ($documents as $document) { + $document->removeAttribute($key); + } + return []; + } - $related = $this->getDocument($relatedCollection->getId(), $value, $queries); + $childIds = []; + foreach ($documents as $document) { + $childId = $document->getId(); + $childIds[] = $childId; + } - $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchStack); + $childIds = array_unique($childIds); - $document->setAttribute($key, $related); - break; - } + if (empty($childIds)) { + return []; + } - if (!$twoWay) { - $document->removeAttribute($key); - break; - } + $selectQueries = []; + $otherQueries = []; + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_SELECT) { + $selectQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } - if ($this->relationshipFetchDepth === Database::RELATION_MAX_DEPTH || $skipFetch) { - break; - } + $relatedDocuments = []; - $this->relationshipFetchDepth++; - $this->relationshipFetchStack[] = $relationship; + foreach (\array_chunk($childIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + $chunkDocs = $this->find($relatedCollection->getId(), [ + Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX), + ...$otherQueries + ]); + \array_push($relatedDocuments, ...$chunkDocs); + } - $relatedDocuments = $this->find($relatedCollection->getId(), [ - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX), - ...$queries - ]); + // Group related documents by child ID + $relatedByChildId = []; + foreach ($relatedDocuments as $related) { + $childId = $related->getAttribute($twoWayKey); + if (!\is_null($childId)) { + // Handle case where childId might be a Document object instead of string + $childKey = $childId instanceof Document + ? $childId->getId() + : $childId; - $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchStack); + if (!isset($relatedByChildId[$childKey])) { + $relatedByChildId[$childKey] = []; + } + // We don't remove the back-reference here because documents may be reused across fetches + // Cycles are prevented by depth limiting in breadth-first traversal + $relatedByChildId[$childKey][] = $related; + } + } + $this->applySelectFiltersToDocuments($relatedDocuments, $selectQueries); - foreach ($relatedDocuments as $related) { - $related->removeAttribute($twoWayKey); - } + foreach ($documents as $document) { + $childId = $document->getId(); + $document->setAttribute($key, $relatedByChildId[$childId] ?? []); + } - $document->setAttribute($key, $relatedDocuments); - break; - case Database::RELATION_MANY_TO_MANY: - if (!$twoWay && $side === Database::RELATION_SIDE_CHILD) { - break; - } + return $relatedDocuments; + } - if ($twoWay && ($this->relationshipFetchDepth === Database::RELATION_MAX_DEPTH || $skipFetch)) { - break; - } + /** + * Populate many-to-many relationships in batch + * + * @param array $documents + * @param Document $relationship + * @param array $queries + * @return array + * @throws DatabaseException + */ + private function populateManyToManyRelationshipsBatch( + array $documents, + Document $relationship, + array $queries + ): array { + $key = $relationship['key']; + $twoWay = $relationship['options']['twoWay']; + $twoWayKey = $relationship['options']['twoWayKey']; + $side = $relationship['options']['side']; + $relatedCollection = $this->getCollection($relationship['options']['relatedCollection']); + $collection = $this->getCollection($relationship->getAttribute('collection')); - $this->relationshipFetchDepth++; - $this->relationshipFetchStack[] = $relationship; + if (!$twoWay && $side === Database::RELATION_SIDE_CHILD) { + return []; + } - $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + $documentIds = []; + foreach ($documents as $document) { + $documentId = $document->getId(); + $documentIds[] = $documentId; + } - $junctions = $this->skipRelationships(fn () => $this->find($junction, [ - Query::equal($twoWayKey, [$document->getId()]), - Query::limit(PHP_INT_MAX) - ])); + $documentIds = array_unique($documentIds); - $relatedIds = []; - foreach ($junctions as $junction) { - $relatedIds[] = $junction->getAttribute($key); - } + if (empty($documentIds)) { + return []; + } - $related = []; - if (!empty($relatedIds)) { - $foundRelated = $this->find($relatedCollection->getId(), [ - Query::equal('$id', $relatedIds), - Query::limit(PHP_INT_MAX), - ...$queries - ]); + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - // Preserve the order of related documents to match the junction order - $relatedById = []; - foreach ($foundRelated as $doc) { - $relatedById[$doc->getId()] = $doc; - } + $junctions = []; - foreach ($relatedIds as $relatedId) { - if (isset($relatedById[$relatedId])) { - $related[] = $relatedById[$relatedId]; - } - } + foreach (\array_chunk($documentIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + $chunkJunctions = $this->skipRelationships(fn () => $this->find($junction, [ + Query::equal($twoWayKey, $chunk), + Query::limit(PHP_INT_MAX) + ])); + \array_push($junctions, ...$chunkJunctions); + } + + $relatedIds = []; + $junctionsByDocumentId = []; + + foreach ($junctions as $junctionDoc) { + $documentId = $junctionDoc->getAttribute($twoWayKey); + $relatedId = $junctionDoc->getAttribute($key); + + if (!\is_null($documentId) && !\is_null($relatedId)) { + if (!isset($junctionsByDocumentId[$documentId])) { + $junctionsByDocumentId[$documentId] = []; + } + $junctionsByDocumentId[$documentId][] = $relatedId; + $relatedIds[] = $relatedId; + } + } + + $related = []; + $allRelatedDocs = []; + if (!empty($relatedIds)) { + $uniqueRelatedIds = array_unique($relatedIds); + $foundRelated = []; + + foreach (\array_chunk($uniqueRelatedIds, self::RELATION_QUERY_CHUNK_SIZE) as $chunk) { + $chunkDocs = $this->find($relatedCollection->getId(), [ + Query::equal('$id', $chunk), + Query::limit(PHP_INT_MAX), + ...$queries + ]); + \array_push($foundRelated, ...$chunkDocs); + } + + $allRelatedDocs = $foundRelated; + + $relatedById = []; + foreach ($foundRelated as $doc) { + $relatedById[$doc->getId()] = $doc; + } + + // Build final related arrays maintaining junction order + foreach ($junctionsByDocumentId as $documentId => $relatedDocIds) { + $documentRelated = []; + foreach ($relatedDocIds as $relatedId) { + if (isset($relatedById[$relatedId])) { + $documentRelated[] = $relatedById[$relatedId]; } + } + $related[$documentId] = $documentRelated; + } + } - $this->relationshipFetchDepth--; - \array_pop($this->relationshipFetchStack); + foreach ($documents as $document) { + $documentId = $document->getId(); + $document->setAttribute($key, $related[$documentId] ?? []); + } - $document->setAttribute($key, $related); - break; + return $allRelatedDocs; + } + + /** + * Apply select filters to documents after fetching + * + * Filters document attributes based on select queries while preserving internal attributes. + * This is used in batch relationship population to apply selects after grouping. + * + * @param array $documents Documents to filter + * @param array $selectQueries Select query objects + * @return void + */ + private function applySelectFiltersToDocuments(array $documents, array $selectQueries): void + { + if (empty($selectQueries) || empty($documents)) { + return; + } + + // Collect all fields to keep from select queries + $fieldsToKeep = []; + foreach ($selectQueries as $selectQuery) { + foreach ($selectQuery->getValues() as $value) { + $fieldsToKeep[$value] = true; } } - return $document; + // Early return if wildcard selector present + if (isset($fieldsToKeep['*'])) { + return; + } + + // Always preserve internal attributes (use hashmap for O(1) lookup) + $internalKeys = \array_map(fn ($attr) => $attr['$id'], $this->getInternalAttributes()); + foreach ($internalKeys as $key) { + $fieldsToKeep[$key] = true; + } + + foreach ($documents as $doc) { + $allKeys = \array_keys($doc->getArrayCopy()); + foreach ($allKeys as $attrKey) { + // Keep if: explicitly selected OR is internal attribute ($ prefix) + if (!isset($fieldsToKeep[$attrKey]) && !\str_starts_with($attrKey, '$')) { + $doc->removeAttribute($attrKey); + } + } + } } /** @@ -3918,8 +4254,11 @@ public function createDocument(string $collection, Document $document): Document return $this->adapter->createDocument($collection, $document); }); - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { + // Use the write stack depth for proper MAX_DEPTH enforcement during creation + $fetchDepth = count($this->relationshipWriteStack); + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $fetchDepth)); + $document = $documents[0]; } $document = $this->casting($collection, $document); @@ -4019,11 +4358,11 @@ public function createDocuments( $batch = $this->adapter->getSequences($collection->getId(), $batch); - foreach ($batch as $document) { - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); - } + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { + $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); + } + foreach ($batch as $document) { $document = $this->casting($collection, $document); $document = $this->decode($collection, $document); @@ -4571,8 +4910,9 @@ public function updateDocument(string $collection, string $id, Document $documen return $document; } - if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->populateDocumentRelationships($collection, $document)); + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { + $documents = $this->silent(fn () => $this->populateDocumentsRelationships([$document], $collection, $this->relationshipFetchDepth)); + $document = $documents[0]; } $document = $this->decode($collection, $document); @@ -5442,11 +5782,11 @@ public function upsertDocumentsWithIncrease( } } - foreach ($batch as $index => $doc) { - if ($this->resolveRelationships) { - $doc = $this->silent(fn () => $this->populateDocumentRelationships($collection, $doc)); - } + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships) { + $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); + } + foreach ($batch as $index => $doc) { $doc = $this->decode($collection, $doc); if ($this->getSharedTables() && $this->getTenantPerDocument()) { @@ -6439,25 +6779,37 @@ public function find(string $collection, array $queries = [], string $forPermiss $selections = $this->validateSelections($collection, $selects); $nestedSelections = $this->processRelationshipQueries($relationships, $queries); - $getResults = fn () => $this->adapter->find( - $collection, - $queries, - $limit ?? 25, - $offset ?? 0, - $orderAttributes, - $orderTypes, - $cursor, - $cursorDirection, - $forPermission - ); + // Convert relationship filter queries to SQL-level subqueries + $queriesOrNull = $this->convertRelationshipFiltersToSubqueries($relationships, $queries); - $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); + // If conversion returns null, it means no documents can match (relationship filter found no matches) + if ($queriesOrNull === null) { + $results = []; + } else { + $queries = $queriesOrNull; - foreach ($results as $index => $node) { - if ($this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { - $node = $this->silent(fn () => $this->populateDocumentRelationships($collection, $node, $nestedSelections)); + $getResults = fn () => $this->adapter->find( + $collection, + $queries, + $limit ?? 25, + $offset ?? 0, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $forPermission + ); + + $results = $skipAuth ? Authorization::skip($getResults) : $getResults(); + } + + if (!$this->inBatchRelationshipPopulation && $this->resolveRelationships && !empty($relationships) && (empty($selects) || !empty($nestedSelections))) { + if (count($results) > 0) { + $results = $this->silent(fn () => $this->populateDocumentsRelationships($results, $collection, $this->relationshipFetchDepth, $nestedSelections)); } + } + foreach ($results as $index => $node) { $node = $this->casting($collection, $node); $node = $this->decode($collection, $node, $selections); @@ -6595,9 +6947,22 @@ public function count(string $collection, array $queries = [], ?int $max = null) $skipAuth = true; } + $relationships = \array_filter( + $collection->getAttribute('attributes', []), + fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP + ); + $queries = Query::groupByType($queries)['filters']; $queries = $this->convertQueries($collection, $queries); + $queriesOrNull = $this->convertRelationshipFiltersToSubqueries($relationships, $queries); + + if ($queriesOrNull === null) { + return 0; + } + + $queries = $queriesOrNull; + $getCount = fn () => $this->adapter->count($collection, $queries, $max); $count = $skipAuth ?? false ? Authorization::skip($getCount) : $getCount(); @@ -6641,8 +7006,22 @@ public function sum(string $collection, string $attribute, array $queries = [], } } + $relationships = \array_filter( + $collection->getAttribute('attributes', []), + fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP + ); + $queries = $this->convertQueries($collection, $queries); + $queriesOrNull = $this->convertRelationshipFiltersToSubqueries($relationships, $queries); + + // If conversion returns null, it means no documents can match (relationship filter found no matches) + if ($queriesOrNull === null) { + return 0; + } + + $queries = $queriesOrNull; + $sum = $this->adapter->sum($collection, $attribute, $queries, $max); $this->trigger(self::EVENT_DOCUMENT_SUM, $sum); @@ -7011,7 +7390,7 @@ private function validateSelections(Document $collection, array $queries): array // Allow querying internal attributes $keys = \array_map( fn ($attribute) => $attribute['$id'], - self::getInternalAttributes() + $this->getInternalAttributes() ); foreach ($collection->getAttribute('attributes', []) as $attribute) { @@ -7260,6 +7639,7 @@ private function processRelationshipQueries( // 'foo.bar.baz' becomes 'bar.baz' $nestingPath = \implode('.', $nesting); + // If nestingPath is empty, it means we want all fields (*) for this relationship if (empty($nestingPath)) { $nestedSelections[$selectedKey][] = Query::select(['*']); @@ -7306,6 +7686,401 @@ private function processRelationshipQueries( return $nestedSelections; } + /** + * Process nested relationship path iteratively + * + * Instead of recursive calls, this method processes multi-level queries in a single loop + * working from the deepest level up to minimize database queries. + * + * Example: For "project.employee.company.name": + * 1. Query companies matching name filter -> IDs [c1, c2] + * 2. Query employees with company IN [c1, c2] -> IDs [e1, e2, e3] + * 3. Query projects with employee IN [e1, e2, e3] -> IDs [p1, p2] + * 4. Return [p1, p2] + * + * @param string $startCollection The starting collection for the path + * @param array $queries Queries with nested paths + * @return array|null Array of matching IDs or null if no matches + */ + private function processNestedRelationshipPath(string $startCollection, array $queries): ?array + { + // Build a map of all nested paths and their queries + $pathGroups = []; + foreach ($queries as $query) { + $attribute = $query->getAttribute(); + if (\str_contains($attribute, '.')) { + $parts = \explode('.', $attribute); + $pathKey = \implode('.', \array_slice($parts, 0, -1)); // Everything except the last part + if (!isset($pathGroups[$pathKey])) { + $pathGroups[$pathKey] = []; + } + $pathGroups[$pathKey][] = [ + 'method' => $query->getMethod(), + 'field' => \end($parts), // The actual field to query + 'values' => $query->getValues(), + ]; + } + } + + $allMatchingIds = []; + foreach ($pathGroups as $path => $queryGroup) { + $pathParts = \explode('.', $path); + $currentCollection = $startCollection; + $relationshipChain = []; + + foreach ($pathParts as $relationshipKey) { + $collectionDoc = $this->silent(fn () => $this->getCollection($currentCollection)); + $relationships = \array_filter( + $collectionDoc->getAttribute('attributes', []), + fn ($attr) => $attr['type'] === self::VAR_RELATIONSHIP + ); + + $relationship = null; + foreach ($relationships as $rel) { + if ($rel['key'] === $relationshipKey) { + $relationship = $rel; + break; + } + } + + if (!$relationship) { + return null; + } + + $relationshipChain[] = [ + 'key' => $relationshipKey, + 'fromCollection' => $currentCollection, + 'toCollection' => $relationship['options']['relatedCollection'], + 'relationType' => $relationship['options']['relationType'], + 'side' => $relationship['options']['side'], + 'twoWayKey' => $relationship['options']['twoWayKey'], + ]; + + $currentCollection = $relationship['options']['relatedCollection']; + } + + // Now walk backwards from the deepest collection to the starting collection + $leafQueries = []; + foreach ($queryGroup as $q) { + $leafQueries[] = new Query($q['method'], $q['field'], $q['values']); + } + + // Query the deepest collection + $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + $currentCollection, + \array_merge($leafQueries, [ + Query::select(['$id']), + Query::limit(PHP_INT_MAX), + ]) + ))); + + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); + + if (empty($matchingIds)) { + return null; + } + + // Walk back up the chain + for ($i = \count($relationshipChain) - 1; $i >= 0; $i--) { + $link = $relationshipChain[$i]; + $relationType = $link['relationType']; + $side = $link['side']; + + // Determine how to query the parent collection + $needsReverseLookup = ( + ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || + ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || + ($relationType === self::RELATION_MANY_TO_MANY) + ); + + if ($needsReverseLookup) { + // Need to find parents by querying children and extracting parent IDs + $childDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + $link['toCollection'], + [ + Query::equal('$id', $matchingIds), + Query::select(['$id', $link['twoWayKey']]), + Query::limit(PHP_INT_MAX), + ] + ))); + + $parentIds = []; + foreach ($childDocs as $doc) { + $parentValue = $doc->getAttribute($link['twoWayKey']); + if (\is_array($parentValue)) { + foreach ($parentValue as $pId) { + if ($pId instanceof Document) { + $pId = $pId->getId(); + } + if ($pId && !\in_array($pId, $parentIds)) { + $parentIds[] = $pId; + } + } + } else { + if ($parentValue instanceof Document) { + $parentValue = $parentValue->getId(); + } + if ($parentValue && !\in_array($parentValue, $parentIds)) { + $parentIds[] = $parentValue; + } + } + } + $matchingIds = $parentIds; + } else { + // Can directly filter parent by the relationship key + $parentDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + $link['fromCollection'], + [ + Query::equal($link['key'], $matchingIds), + Query::select(['$id']), + Query::limit(PHP_INT_MAX), + ] + ))); + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $parentDocs); + } + + if (empty($matchingIds)) { + return null; + } + } + + $allMatchingIds = \array_merge($allMatchingIds, $matchingIds); + } + + return \array_unique($allMatchingIds); + } + + /** + * Convert relationship filter queries to SQL-safe subqueries recursively + * + * Queries like Query::equal('author.name', ['Alice']) are converted to + * Query::equal('author', []) + * + * This method supports multi-level nested relationship queries: + * - Depth 1: employee.name + * - Depth 2: employee.company.name + * - Depth 3: project.employee.company.name + * + * The method works by: + * 1. Parsing dot-path queries (e.g., "project.employee.company.name") + * 2. Extracting the first relationship (e.g., "project") + * 3. If the nested field still contains dots, using iterative processing + * 4. Finding matching documents in the related collection + * 5. Converting to filters on the parent collection + * + * @param array $relationships + * @param array $queries + * @return array|null Returns null if relationship filters cannot match any documents + */ + private function convertRelationshipFiltersToSubqueries( + array $relationships, + array $queries, + ): ?array { + // Early return if no dot-path queries exist + $hasDotPath = false; + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (\str_contains($attr, '.')) { + $hasDotPath = true; + break; + } + } + + if (!$hasDotPath) { + return $queries; + } + + $relationshipsByKey = []; + foreach ($relationships as $relationship) { + $relationshipsByKey[$relationship->getAttribute('key')] = $relationship; + } + + $additionalQueries = []; + $groupedQueries = []; + $indicesToRemove = []; + + // Group queries by relationship key + foreach ($queries as $index => $query) { + if ($query->getMethod() === Query::TYPE_SELECT) { + continue; + } + $method = $query->getMethod(); + $attribute = $query->getAttribute(); + + if (!\str_contains($attribute, '.')) { + continue; + } + + // Parse the relationship path + $parts = \explode('.', $attribute); + $relationshipKey = \array_shift($parts); + $nestedField = \implode('.', $parts); + $relationship = $relationshipsByKey[$relationshipKey] ?? null; + + if (!$relationship) { + continue; + } + + // Group queries by relationship key + if (!isset($groupedQueries[$relationshipKey])) { + $groupedQueries[$relationshipKey] = [ + 'relationship' => $relationship, + 'queries' => [], + 'indices' => [] + ]; + } + + $groupedQueries[$relationshipKey]['queries'][] = [ + 'method' => $method, + 'field' => $nestedField, + 'values' => $query->getValues() + ]; + + $groupedQueries[$relationshipKey]['indices'][] = $index; + } + + // Process each relationship group + foreach ($groupedQueries as $relationshipKey => $group) { + $relationship = $group['relationship']; + $relatedCollection = $relationship->getAttribute('options')['relatedCollection']; + $relationType = $relationship->getAttribute('options')['relationType']; + $side = $relationship->getAttribute('options')['side']; + + // Build combined queries for the related collection + $relatedQueries = []; + foreach ($group['queries'] as $queryData) { + $relatedQueries[] = new Query( + $queryData['method'], + $queryData['field'], + $queryData['values'] + ); + } + + try { + // Process multi-level queries by walking the relationship chain from deepest to shallowest + // For example: project.employee.company.name + // 1. Find companies matching name -> company IDs + // 2. Find employees with those company IDs -> employee IDs + // 3. Find projects with those employee IDs -> project IDs + + // Check if we have nested relationships (depth 2+) + $hasNestedPaths = false; + $deepestQuery = null; + foreach ($relatedQueries as $relatedQuery) { + if (\str_contains($relatedQuery->getAttribute(), '.')) { + $hasNestedPaths = true; + $deepestQuery = $relatedQuery; + break; + } + } + + if ($hasNestedPaths) { + // Process the nested path iteratively from deepest to shallowest + $matchingIds = $this->processNestedRelationshipPath( + $relatedCollection, + $relatedQueries + ); + + if ($matchingIds === null || empty($matchingIds)) { + return null; + } + + // Convert to simple ID filter for the current level + $relatedQueries = [Query::equal('$id', $matchingIds)]; + } + + // For virtual parent relationships (where parent doesn't store child IDs), + // we need to find which parents have matching children + // - ONE_TO_MANY from parent side: parent doesn't store children + // - MANY_TO_ONE from child side: the "one" side doesn't store "many" IDs + // - MANY_TO_MANY: both sides are virtual, stored in junction table + $needsParentResolution = ( + ($relationType === self::RELATION_ONE_TO_MANY && $side === self::RELATION_SIDE_PARENT) || + ($relationType === self::RELATION_MANY_TO_ONE && $side === self::RELATION_SIDE_CHILD) || + ($relationType === self::RELATION_MANY_TO_MANY) + ); + + if ($needsParentResolution) { + $matchingDocs = $this->silent(fn () => $this->find( + $relatedCollection, + \array_merge($relatedQueries, [ + Query::limit(PHP_INT_MAX), + ]) + )); + } else { + $matchingDocs = $this->silent(fn () => $this->skipRelationships(fn () => $this->find( + $relatedCollection, + \array_merge($relatedQueries, [ + Query::select(['$id']), + Query::limit(PHP_INT_MAX), + ]) + ))); + } + + $matchingIds = \array_map(fn ($doc) => $doc->getId(), $matchingDocs); + + if ($needsParentResolution) { + // Need to find which parents have these children + $twoWayKey = $relationship->getAttribute('options')['twoWayKey']; + + $parentIds = []; + foreach ($matchingDocs as $doc) { + $parentId = $doc->getAttribute($twoWayKey); + + // Handle MANY_TO_MANY: twoWayKey returns an array + if (\is_array($parentId)) { + foreach ($parentId as $id) { + if ($id instanceof Document) { + $id = $id->getId(); + } + if ($id && !\in_array($id, $parentIds)) { + $parentIds[] = $id; + } + } + } else { + // Handle ONE_TO_MANY/MANY_TO_ONE: single value + if ($parentId instanceof Document) { + $parentId = $parentId->getId(); + } + if ($parentId && !\in_array($parentId, $parentIds)) { + $parentIds[] = $parentId; + } + } + } + + // Add filter on current collection's $id + if (!empty($parentIds)) { + $additionalQueries[] = Query::equal('$id', $parentIds); + } else { + return null; + } + } else { + // For other types, filter by the relationship field + if (!empty($matchingIds)) { + $additionalQueries[] = Query::equal($relationshipKey, $matchingIds); + } else { + return null; + } + } + + // Remove all original relationship queries for this group + foreach ($group['indices'] as $originalIndex) { + $indicesToRemove[] = $originalIndex; + } + } catch (\Exception $e) { + return null; + } + } + + // Remove the original queries + foreach ($indicesToRemove as $index) { + unset($queries[$index]); + } + + // Merge additional queries + return \array_merge(\array_values($queries), $additionalQueries); + } + /** * Encode spatial data from array format to WKT (Well-Known Text) format * diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index 289ccbe5b..92d4e4e69 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -27,7 +27,7 @@ public function __construct( array $attributes, array $indexes, string $idAttributeType, - int $maxValuesCount = 100, + int $maxValuesCount = 5000, \DateTime $minAllowedDate = new \DateTime('0000-01-01'), \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), ) { diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 9c60f551c..5bc973f22 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -28,7 +28,7 @@ class Filter extends Base public function __construct( array $attributes, private readonly string $idAttributeType, - private readonly int $maxValuesCount = 100, + private readonly int $maxValuesCount = 5000, private readonly \DateTime $minAllowedDate = new \DateTime('0000-01-01'), private readonly \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), ) { @@ -59,11 +59,6 @@ protected function isValidAttribute(string $attribute): bool // For relationships, just validate the top level. // will validate each nested level during the recursive calls. $attribute = \explode('.', $attribute)[0]; - - if (isset($this->schema[$attribute])) { - $this->message = 'Cannot query nested attribute on: ' . $attribute; - return false; - } } // Search for attribute in schema @@ -87,6 +82,7 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s return false; } + $originalAttribute = $attribute; // isset check if for special symbols "." in the attribute name if (\str_contains($attribute, '.') && !isset($this->schema[$attribute])) { // For relationships, just validate the top level. @@ -96,6 +92,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s $attributeSchema = $this->schema[$attribute]; + // Skip value validation for nested relationship queries (e.g., author.age) + // The values will be validated when querying the related collection + if ($attributeSchema['type'] === Database::VAR_RELATIONSHIP && $originalAttribute !== $attribute) { + return true; + } + if (count($values) > $this->maxValuesCount) { $this->message = 'Query on attribute has greater than ' . $this->maxValuesCount . ' values: ' . $attribute; return false; @@ -330,6 +332,11 @@ public function isValid($value): bool } } + public function getMaxValuesCount(): int + { + return $this->maxValuesCount; + } + public function getMethodType(): string { return self::METHOD_TYPE_FILTER; diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index 9cc520b4f..9487f55d1 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -28,6 +28,17 @@ public function __construct(array $attributes = []) */ protected function isValidAttribute(string $attribute): bool { + if (\str_contains($attribute, '.')) { + // Check for special symbol `.` + if (isset($this->schema[$attribute])) { + return true; + } + + // For relationships, just validate the top level. + // Will validate each nested level during the recursive calls. + $attribute = \explode('.', $attribute)[0]; + } + // Search for attribute in schema if (!isset($this->schema[$attribute])) { $this->message = 'Attribute not found in schema: ' . $attribute; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 1196aa841..8f53a9c23 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -330,6 +330,35 @@ public function testCreateDocument(): Document return $document; } + public function testCreateDocumentNumericalId(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + $database->createCollection('numericalIds'); + + $this->assertEquals(true, $database->createAttribute('numericalIds', 'name', Database::VAR_STRING, 128, true)); + + // Test creating a document with an entirely numerical ID + $numericalIdDocument = $database->createDocument('numericalIds', new Document([ + '$id' => '123456789', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'Test Document with Numerical ID', + ])); + + $this->assertIsString($numericalIdDocument->getId()); + $this->assertEquals('123456789', $numericalIdDocument->getId()); + $this->assertEquals('Test Document with Numerical ID', $numericalIdDocument->getAttribute('name')); + + // Verify we can retrieve the document + $retrievedDocument = $database->getDocument('numericalIds', '123456789'); + $this->assertIsString($retrievedDocument->getId()); + $this->assertEquals('123456789', $retrievedDocument->getId()); + $this->assertEquals('Test Document with Numerical ID', $retrievedDocument->getAttribute('name')); + } + public function testCreateDocuments(): void { $count = 3; diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index d3c85ea2e..d93e88a5b 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -401,6 +401,78 @@ public function testZoo(): void $this->assertEquals('Bronx Zoo', $animal->getAttribute('zoo')->getAttribute('name')); // Check zoo is an object } + public function testSimpleRelationshipPopulation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Simple test case: user -> post (one-to-many) + $database->createCollection('users_simple'); + $database->createCollection('posts_simple'); + + $database->createAttribute('users_simple', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('posts_simple', 'title', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'users_simple', + relatedCollection: 'posts_simple', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'posts', + twoWayKey: 'author' + ); + + // Create some data + $user = $database->createDocument('users_simple', new Document([ + '$id' => 'user1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'John Doe', + ])); + + $post1 = $database->createDocument('posts_simple', new Document([ + '$id' => 'post1', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'First Post', + 'author' => 'user1', + ])); + + $post2 = $database->createDocument('posts_simple', new Document([ + '$id' => 'post2', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Second Post', + 'author' => 'user1', + ])); + + // Test: fetch user with posts populated + $fetchedUser = $database->getDocument('users_simple', 'user1'); + $posts = $fetchedUser->getAttribute('posts', []); + + // Basic assertions + $this->assertIsArray($posts, 'Posts should be an array'); + $this->assertCount(2, $posts, 'Should have 2 posts'); + + if (!empty($posts)) { + $this->assertInstanceOf(Document::class, $posts[0], 'First post should be a Document object'); + $this->assertEquals('First Post', $posts[0]->getAttribute('title'), 'First post title should be populated'); + } + + // Test: fetch posts with author populated + $fetchedPosts = $database->find('posts_simple'); + + $this->assertCount(2, $fetchedPosts, 'Should fetch 2 posts'); + + if (!empty($fetchedPosts)) { + $author = $fetchedPosts[0]->getAttribute('author'); + $this->assertInstanceOf(Document::class, $author, 'Author should be a Document object'); + $this->assertEquals('John Doe', $author->getAttribute('name'), 'Author name should be populated'); + } + } + public function testDeleteRelatedCollection(): void { /** @var Database $database */ @@ -2799,4 +2871,1302 @@ public function testMultiDocumentNestedRelationships(): void $database->deleteCollection('car'); $database->deleteCollection('customer'); } + + /** + * Test that nested document creation properly populates relationships at all depths. + * This test verifies the fix for the depth handling bug where populateDocumentsRelationships() + * would early return for non-zero depth, causing nested documents to not have their relationships populated. + */ + public function testNestedDocumentCreationWithDepthHandling(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create three collections with chained relationships: Order -> Product -> Store + $database->createCollection('order_depth_test'); + $database->createCollection('product_depth_test'); + $database->createCollection('store_depth_test'); + + $database->createAttribute('order_depth_test', 'orderNumber', Database::VAR_STRING, 255, true); + $database->createAttribute('product_depth_test', 'productName', Database::VAR_STRING, 255, true); + $database->createAttribute('store_depth_test', 'storeName', Database::VAR_STRING, 255, true); + + // Order -> Product (many-to-one) + $database->createRelationship( + collection: 'order_depth_test', + relatedCollection: 'product_depth_test', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'product', + twoWayKey: 'orders' + ); + + // Product -> Store (many-to-one) + $database->createRelationship( + collection: 'product_depth_test', + relatedCollection: 'store_depth_test', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'store', + twoWayKey: 'products' + ); + + // First, create a store that will be referenced by the nested product + $store = $database->createDocument('store_depth_test', new Document([ + '$id' => 'store1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'storeName' => 'Main Store', + ])); + + $this->assertEquals('store1', $store->getId()); + $this->assertEquals('Main Store', $store->getAttribute('storeName')); + + // Create an order with a nested product that references the existing store + // The nested product is created at depth 1 + // With the bug, the product's relationships (including 'store') would not be populated + // With the fix, the product's 'store' relationship should be properly populated + $order = $database->createDocument('order_depth_test', new Document([ + '$id' => 'order1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'orderNumber' => 'ORD-001', + 'product' => [ + '$id' => 'product1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'productName' => 'Widget', + 'store' => 'store1', // Reference to existing store + ], + ])); + + // Verify the order was created + $this->assertEquals('order1', $order->getId()); + $this->assertEquals('ORD-001', $order->getAttribute('orderNumber')); + + // Verify the nested product relationship is populated (depth 1) + $this->assertArrayHasKey('product', $order); + $product = $order->getAttribute('product'); + $this->assertInstanceOf(Document::class, $product); + $this->assertEquals('product1', $product->getId()); + $this->assertEquals('Widget', $product->getAttribute('productName')); + + // CRITICAL: Verify the product's store relationship is populated (depth 2) + // This is the key assertion that would fail with the bug + $this->assertArrayHasKey('store', $product); + $productStore = $product->getAttribute('store'); + $this->assertInstanceOf(Document::class, $productStore); + $this->assertEquals('store1', $productStore->getId()); + $this->assertEquals('Main Store', $productStore->getAttribute('storeName')); + + // Also test with update - create another order and update it with nested product + $order2 = $database->createDocument('order_depth_test', new Document([ + '$id' => 'order2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'orderNumber' => 'ORD-002', + ])); + + // Update order2 to add a nested product + $order2Updated = $database->updateDocument('order_depth_test', 'order2', $order2->setAttribute('product', [ + '$id' => 'product2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'productName' => 'Gadget', + 'store' => 'store1', + ])); + + // Verify the updated order has the nested product with populated store + $this->assertEquals('order2', $order2Updated->getId()); + $product2 = $order2Updated->getAttribute('product'); + $this->assertInstanceOf(Document::class, $product2); + $this->assertEquals('product2', $product2->getId()); + + // Verify the product's store is populated after update + $this->assertArrayHasKey('store', $product2); + $product2Store = $product2->getAttribute('store'); + $this->assertInstanceOf(Document::class, $product2Store); + $this->assertEquals('store1', $product2Store->getId()); + + // Clean up + $database->deleteCollection('order_depth_test'); + $database->deleteCollection('product_depth_test'); + $database->deleteCollection('store_depth_test'); + } + + /** + * Test filtering by relationship fields using dot-path notation + */ + public function testRelationshipFiltering(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create Author -> Posts relationship + $database->createCollection('authors_filter'); + $database->createCollection('posts_filter'); + + $database->createAttribute('authors_filter', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('authors_filter', 'age', Database::VAR_INTEGER, 0, true); + $database->createAttribute('posts_filter', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('posts_filter', 'published', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'authors_filter', + relatedCollection: 'posts_filter', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'posts', + twoWayKey: 'author' + ); + + // Create test data + $author1 = $database->createDocument('authors_filter', new Document([ + '$id' => 'author1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Alice', + 'age' => 30, + ])); + + $author2 = $database->createDocument('authors_filter', new Document([ + '$id' => 'author2', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Bob', + 'age' => 25, + ])); + + // Create posts + $database->createDocument('posts_filter', new Document([ + '$id' => 'post1', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 1', + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_filter', new Document([ + '$id' => 'post2', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 2', + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_filter', new Document([ + '$id' => 'post3', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Bob Post', + 'published' => true, + 'author' => 'author2', + ])); + + // Test: Filter posts by author name + $posts = $database->find('posts_filter', [ + Query::equal('author.name', ['Alice']), + ]); + $this->assertCount(2, $posts); + $this->assertEquals('post1', $posts[0]->getId()); + $this->assertEquals('post2', $posts[1]->getId()); + + // Test: Filter posts by author age + $posts = $database->find('posts_filter', [ + Query::lessThan('author.age', 30), + ]); + $this->assertCount(1, $posts); + $this->assertEquals('post3', $posts[0]->getId()); + + // Test: Filter authors by their posts' published status + $authors = $database->find('authors_filter', [ + Query::equal('posts.published', [true]), + ]); + $this->assertCount(2, $authors); // Both authors have published posts + + // Clean up ONE_TO_MANY test + $database->deleteCollection('authors_filter'); + $database->deleteCollection('posts_filter'); + + // ==================== Test ONE_TO_ONE relationships ==================== + $database->createCollection('users_oto'); + $database->createCollection('profiles_oto'); + + $database->createAttribute('users_oto', 'username', Database::VAR_STRING, 255, true); + $database->createAttribute('profiles_oto', 'bio', Database::VAR_STRING, 255, true); + + // ONE_TO_ONE with twoWay=true + $database->createRelationship( + collection: 'users_oto', + relatedCollection: 'profiles_oto', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'profile', + twoWayKey: 'user' + ); + + $user1 = $database->createDocument('users_oto', new Document([ + '$id' => 'user1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'username' => 'alice', + ])); + + $profile1 = $database->createDocument('profiles_oto', new Document([ + '$id' => 'profile1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'bio' => 'Software Engineer', + 'user' => 'user1', + ])); + + // Test: Filter profiles by user username + $profiles = $database->find('profiles_oto', [ + Query::equal('user.username', ['alice']), + ]); + $this->assertCount(1, $profiles); + $this->assertEquals('profile1', $profiles[0]->getId()); + + // Test: Filter users by profile bio + $users = $database->find('users_oto', [ + Query::equal('profile.bio', ['Software Engineer']), + ]); + $this->assertCount(1, $users); + $this->assertEquals('user1', $users[0]->getId()); + + // Clean up ONE_TO_ONE test + $database->deleteCollection('users_oto'); + $database->deleteCollection('profiles_oto'); + + // ==================== Test MANY_TO_ONE relationships ==================== + $database->createCollection('comments_mto'); + $database->createCollection('users_mto'); + + $database->createAttribute('comments_mto', 'content', Database::VAR_STRING, 255, true); + $database->createAttribute('users_mto', 'name', Database::VAR_STRING, 255, true); + + // MANY_TO_ONE with twoWay=true + $database->createRelationship( + collection: 'comments_mto', + relatedCollection: 'users_mto', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'commenter', + twoWayKey: 'comments' + ); + + $userA = $database->createDocument('users_mto', new Document([ + '$id' => 'userA', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Alice', + ])); + + $comment1 = $database->createDocument('comments_mto', new Document([ + '$id' => 'comment1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'content' => 'Great post!', + 'commenter' => 'userA', + ])); + + $comment2 = $database->createDocument('comments_mto', new Document([ + '$id' => 'comment2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'content' => 'Nice work!', + 'commenter' => 'userA', + ])); + + // Test: Filter comments by commenter name + $comments = $database->find('comments_mto', [ + Query::equal('commenter.name', ['Alice']), + ]); + $this->assertCount(2, $comments); + + // Test: Filter users by their comments' content + $users = $database->find('users_mto', [ + Query::equal('comments.content', ['Great post!']), + ]); + $this->assertCount(1, $users); + $this->assertEquals('userA', $users[0]->getId()); + + // Clean up MANY_TO_ONE test + $database->deleteCollection('comments_mto'); + $database->deleteCollection('users_mto'); + + // ==================== Test MANY_TO_MANY relationships ==================== + $database->createCollection('students_mtm'); + $database->createCollection('courses_mtm'); + + $database->createAttribute('students_mtm', 'studentName', Database::VAR_STRING, 255, true); + $database->createAttribute('courses_mtm', 'courseName', Database::VAR_STRING, 255, true); + + // MANY_TO_MANY + $database->createRelationship( + collection: 'students_mtm', + relatedCollection: 'courses_mtm', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'enrolledCourses', + twoWayKey: 'students' + ); + + $student1 = $database->createDocument('students_mtm', new Document([ + '$id' => 'student1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'studentName' => 'John', + ])); + + $course1 = $database->createDocument('courses_mtm', new Document([ + '$id' => 'course1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'courseName' => 'Physics', + 'students' => ['student1'], + ])); + + // Test: Filter students by enrolled course name + $students = $database->find('students_mtm', [ + Query::equal('enrolledCourses.courseName', ['Physics']), + ]); + $this->assertCount(1, $students); + $this->assertEquals('student1', $students[0]->getId()); + + // Test: Filter courses by student name + $courses = $database->find('courses_mtm', [ + Query::equal('students.studentName', ['John']), + ]); + $this->assertCount(1, $courses); + $this->assertEquals('course1', $courses[0]->getId()); + + // Clean up MANY_TO_MANY test + $database->deleteCollection('students_mtm'); + $database->deleteCollection('courses_mtm'); + } + + /** + * Comprehensive test for all query types on relationships + */ + public function testRelationshipQueryTypes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup test collections + $database->createCollection('products_qt'); + $database->createCollection('vendors_qt'); + + $database->createAttribute('products_qt', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('products_qt', 'price', Database::VAR_FLOAT, 0, true); + $database->createAttribute('vendors_qt', 'company', Database::VAR_STRING, 255, true); + $database->createAttribute('vendors_qt', 'rating', Database::VAR_FLOAT, 0, true); + $database->createAttribute('vendors_qt', 'email', Database::VAR_STRING, 255, true); + $database->createAttribute('vendors_qt', 'verified', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'products_qt', + relatedCollection: 'vendors_qt', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'vendor', + twoWayKey: 'products' + ); + + // Create test vendors + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'Acme Corp', + 'rating' => 4.5, + 'email' => 'sales@acme.com', + 'verified' => true, + ])); + + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'TechSupply Inc', + 'rating' => 3.8, + 'email' => 'info@techsupply.com', + 'verified' => true, + ])); + + $database->createDocument('vendors_qt', new Document([ + '$id' => 'vendor3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'company' => 'Budget Vendors', + 'rating' => 2.5, + 'email' => 'contact@budget.com', + 'verified' => false, + ])); + + // Create test products + $database->createDocument('products_qt', new Document([ + '$id' => 'product1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget A', + 'price' => 19.99, + 'vendor' => 'vendor1', + ])); + + $database->createDocument('products_qt', new Document([ + '$id' => 'product2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget B', + 'price' => 29.99, + 'vendor' => 'vendor2', + ])); + + $database->createDocument('products_qt', new Document([ + '$id' => 'product3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Widget C', + 'price' => 9.99, + 'vendor' => 'vendor3', + ])); + + // Test: Query::equal() + $products = $database->find('products_qt', [ + Query::equal('vendor.company', ['Acme Corp']) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::notEqual() + $products = $database->find('products_qt', [ + Query::notEqual('vendor.company', ['Budget Vendors']) + ]); + $this->assertCount(2, $products); + + // Test: Query::lessThan() + $products = $database->find('products_qt', [ + Query::lessThan('vendor.rating', 4.0) + ]); + $this->assertCount(2, $products); // vendor2 (3.8) and vendor3 (2.5) + + // Test: Query::lessThanEqual() + $products = $database->find('products_qt', [ + Query::lessThanEqual('vendor.rating', 3.8) + ]); + $this->assertCount(2, $products); + + // Test: Query::greaterThan() + $products = $database->find('products_qt', [ + Query::greaterThan('vendor.rating', 4.0) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::greaterThanEqual() + $products = $database->find('products_qt', [ + Query::greaterThanEqual('vendor.rating', 3.8) + ]); + $this->assertCount(2, $products); // vendor1 (4.5) and vendor2 (3.8) + + // Test: Query::startsWith() + $products = $database->find('products_qt', [ + Query::startsWith('vendor.email', 'sales@') + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Query::endsWith() + $products = $database->find('products_qt', [ + Query::endsWith('vendor.email', '.com') + ]); + $this->assertCount(3, $products); + + // Test: Query::contains() + $products = $database->find('products_qt', [ + Query::contains('vendor.company', ['Corp']) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Test: Boolean query + $products = $database->find('products_qt', [ + Query::equal('vendor.verified', [true]) + ]); + $this->assertCount(2, $products); // vendor1 and vendor2 are verified + + $products = $database->find('products_qt', [ + Query::equal('vendor.verified', [false]) + ]); + $this->assertCount(1, $products); + $this->assertEquals('product3', $products[0]->getId()); + + // Test: Multiple conditions on same relationship (query grouping optimization) + $products = $database->find('products_qt', [ + Query::greaterThan('vendor.rating', 3.0), + Query::equal('vendor.verified', [true]), + Query::startsWith('vendor.company', 'Acme') + ]); + $this->assertCount(1, $products); + $this->assertEquals('product1', $products[0]->getId()); + + // Clean up + $database->deleteCollection('products_qt'); + $database->deleteCollection('vendors_qt'); + } + + /** + * Test edge cases and error scenarios for relationship queries + */ + public function testRelationshipQueryEdgeCases(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup test collections + $database->createCollection('orders_edge'); + $database->createCollection('customers_edge'); + + $database->createAttribute('orders_edge', 'orderNumber', Database::VAR_STRING, 255, true); + $database->createAttribute('orders_edge', 'total', Database::VAR_FLOAT, 0, true); + $database->createAttribute('customers_edge', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('customers_edge', 'age', Database::VAR_INTEGER, 0, true); + + $database->createRelationship( + collection: 'orders_edge', + relatedCollection: 'customers_edge', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'customer', + twoWayKey: 'orders' + ); + + // Create customer + $database->createDocument('customers_edge', new Document([ + '$id' => 'customer1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'John Doe', + 'age' => 30, + ])); + + // Create order + $database->createDocument('orders_edge', new Document([ + '$id' => 'order1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'orderNumber' => 'ORD001', + 'total' => 100.00, + 'customer' => 'customer1', + ])); + + // Edge Case 1: Query with no matching results + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['Jane Doe']) + ]); + $this->assertCount(0, $orders); + + // Edge Case 2: Query with impossible condition (combines to empty set) + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::equal('customer.age', [25]) // John is 30, not 25 + ]); + $this->assertCount(0, $orders); + + // Edge Case 3: Query on non-existent relationship field + try { + $orders = $database->find('orders_edge', [ + Query::equal('nonexistent.field', ['value']) + ]); + // Should return empty or throw - either is acceptable + $this->assertCount(0, $orders); + } catch (\Exception $e) { + // Expected - non-existent relationship + $this->assertTrue(true); + } + + // Edge Case 4: Empty array values (should throw exception) + try { + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', []) + ]); + $this->fail('Expected exception for empty array values'); + } catch (\Exception $e) { + // Expected - empty array values are invalid + $this->assertStringContainsString('at least one value', $e->getMessage()); + } + + // Edge Case 5: Null or missing relationship + $database->createDocument('orders_edge', new Document([ + '$id' => 'order2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'orderNumber' => 'ORD002', + 'total' => 50.00, + // No customer relationship + ])); + + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']) + ]); + $this->assertCount(1, $orders); // Only order1 has a customer + + // Edge Case 6: Combining relationship query with regular query + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::greaterThan('total', 75.00) + ]); + $this->assertCount(1, $orders); + $this->assertEquals('order1', $orders[0]->getId()); + + // Edge Case 7: Query with limit and offset + $orders = $database->find('orders_edge', [ + Query::equal('customer.name', ['John Doe']), + Query::limit(1), + Query::offset(0) + ]); + $this->assertCount(1, $orders); + + // Clean up + $database->deleteCollection('orders_edge'); + $database->deleteCollection('customers_edge'); + } + + /** + * Test relationship queries from parent side with virtual fields + */ + public function testRelationshipQueryParentSide(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup ONE_TO_MANY relationship + $database->createCollection('teams_parent'); + $database->createCollection('members_parent'); + + $database->createAttribute('teams_parent', 'teamName', Database::VAR_STRING, 255, true); + $database->createAttribute('teams_parent', 'active', Database::VAR_BOOLEAN, 0, true); + $database->createAttribute('members_parent', 'memberName', Database::VAR_STRING, 255, true); + $database->createAttribute('members_parent', 'role', Database::VAR_STRING, 255, true); + $database->createAttribute('members_parent', 'senior', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'teams_parent', + relatedCollection: 'members_parent', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'members', + twoWayKey: 'team' + ); + + // Create teams + $database->createDocument('teams_parent', new Document([ + '$id' => 'team1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'teamName' => 'Engineering', + 'active' => true, + ])); + + $database->createDocument('teams_parent', new Document([ + '$id' => 'team2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'teamName' => 'Sales', + 'active' => true, + ])); + + // Create members + $database->createDocument('members_parent', new Document([ + '$id' => 'member1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Alice', + 'role' => 'Engineer', + 'senior' => true, + 'team' => 'team1', + ])); + + $database->createDocument('members_parent', new Document([ + '$id' => 'member2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Bob', + 'role' => 'Manager', + 'senior' => false, + 'team' => 'team2', + ])); + + $database->createDocument('members_parent', new Document([ + '$id' => 'member3', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'memberName' => 'Charlie', + 'role' => 'Engineer', + 'senior' => true, + 'team' => 'team1', + ])); + + // Test: Find teams that have senior engineers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Engineer']), + Query::equal('members.senior', [true]) + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team1', $teams[0]->getId()); + + // Test: Find teams with managers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Manager']) + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team2', $teams[0]->getId()); + + // Test: Find teams with members named 'Alice' + $teams = $database->find('teams_parent', [ + Query::startsWith('members.memberName', 'A') + ]); + $this->assertCount(1, $teams); + $this->assertEquals('team1', $teams[0]->getId()); + + // Test: No teams with junior managers + $teams = $database->find('teams_parent', [ + Query::equal('members.role', ['Manager']), + Query::equal('members.senior', [true]) + ]); + $this->assertCount(0, $teams); + + // Clean up + $database->deleteCollection('teams_parent'); + $database->deleteCollection('members_parent'); + } + + /** + * Test MANY_TO_MANY relationships with complex queries + */ + public function testRelationshipManyToManyComplex(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Setup MANY_TO_MANY + $database->createCollection('developers_mtm'); + $database->createCollection('projects_mtm'); + + $database->createAttribute('developers_mtm', 'devName', Database::VAR_STRING, 255, true); + $database->createAttribute('developers_mtm', 'experience', Database::VAR_INTEGER, 0, true); + $database->createAttribute('projects_mtm', 'projectName', Database::VAR_STRING, 255, true); + $database->createAttribute('projects_mtm', 'budget', Database::VAR_FLOAT, 0, true); + $database->createAttribute('projects_mtm', 'priority', Database::VAR_STRING, 50, true); + + $database->createRelationship( + collection: 'developers_mtm', + relatedCollection: 'projects_mtm', + type: Database::RELATION_MANY_TO_MANY, + twoWay: true, + id: 'assignedProjects', + twoWayKey: 'assignedDevelopers' + ); + + // Create developers + $dev1 = $database->createDocument('developers_mtm', new Document([ + '$id' => 'dev1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'devName' => 'Senior Dev', + 'experience' => 10, + ])); + + $dev2 = $database->createDocument('developers_mtm', new Document([ + '$id' => 'dev2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'devName' => 'Junior Dev', + 'experience' => 2, + ])); + + // Create projects + $project1 = $database->createDocument('projects_mtm', new Document([ + '$id' => 'proj1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'projectName' => 'High Priority Project', + 'budget' => 100000.00, + 'priority' => 'high', + 'assignedDevelopers' => ['dev1', 'dev2'], + ])); + + $project2 = $database->createDocument('projects_mtm', new Document([ + '$id' => 'proj2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'projectName' => 'Low Priority Project', + 'budget' => 25000.00, + 'priority' => 'low', + 'assignedDevelopers' => ['dev2'], + ])); + + // Test: Find developers on high priority projects + $developers = $database->find('developers_mtm', [ + Query::equal('assignedProjects.priority', ['high']) + ]); + $this->assertCount(2, $developers); // Both assigned to proj1 + + // Test: Find developers on high budget projects + $developers = $database->find('developers_mtm', [ + Query::greaterThan('assignedProjects.budget', 50000.00) + ]); + $this->assertCount(2, $developers); + + // Test: Find projects with experienced developers + $projects = $database->find('projects_mtm', [ + Query::greaterThanEqual('assignedDevelopers.experience', 10) + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj1', $projects[0]->getId()); + + // Test: Find projects with junior developers + $projects = $database->find('projects_mtm', [ + Query::lessThan('assignedDevelopers.experience', 5) + ]); + $this->assertCount(2, $projects); // Both projects have dev2 + + // Test: Combined queries + $projects = $database->find('projects_mtm', [ + Query::equal('assignedDevelopers.devName', ['Junior Dev']), + Query::equal('priority', ['low']) + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj2', $projects[0]->getId()); + + // Clean up + $database->deleteCollection('developers_mtm'); + $database->deleteCollection('projects_mtm'); + } + + public function testNestedRelationshipQueriesMultipleDepths(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create 3-level nested structure: + // Companies -> Employees -> Projects -> Tasks + // Also: Employees -> Department (MANY_TO_ONE) + + // Level 0: Companies + $database->createCollection('companies_nested'); + $database->createAttribute('companies_nested', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('companies_nested', 'industry', Database::VAR_STRING, 255, true); + + // Level 1: Employees + $database->createCollection('employees_nested'); + $database->createAttribute('employees_nested', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('employees_nested', 'role', Database::VAR_STRING, 255, true); + + // Level 1b: Departments (for MANY_TO_ONE) + $database->createCollection('departments_nested'); + $database->createAttribute('departments_nested', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('departments_nested', 'budget', Database::VAR_INTEGER, 0, true); + + // Level 2: Projects + $database->createCollection('projects_nested'); + $database->createAttribute('projects_nested', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('projects_nested', 'status', Database::VAR_STRING, 255, true); + + // Level 3: Tasks + $database->createCollection('tasks_nested'); + $database->createAttribute('tasks_nested', 'description', Database::VAR_STRING, 255, true); + $database->createAttribute('tasks_nested', 'priority', Database::VAR_STRING, 255, true); + $database->createAttribute('tasks_nested', 'completed', Database::VAR_BOOLEAN, 0, true); + + // Create relationships + // Companies -> Employees (ONE_TO_MANY) + $database->createRelationship( + collection: 'companies_nested', + relatedCollection: 'employees_nested', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'employees', + twoWayKey: 'company' + ); + + // Employees -> Department (MANY_TO_ONE) + $database->createRelationship( + collection: 'employees_nested', + relatedCollection: 'departments_nested', + type: Database::RELATION_MANY_TO_ONE, + twoWay: true, + id: 'department', + twoWayKey: 'employees' + ); + + // Employees -> Projects (ONE_TO_MANY) + $database->createRelationship( + collection: 'employees_nested', + relatedCollection: 'projects_nested', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'projects', + twoWayKey: 'employee' + ); + + // Projects -> Tasks (ONE_TO_MANY) + $database->createRelationship( + collection: 'projects_nested', + relatedCollection: 'tasks_nested', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'tasks', + twoWayKey: 'project' + ); + + // Create test data + $dept1 = $database->createDocument('departments_nested', new Document([ + '$id' => 'dept1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Engineering', + 'budget' => 100000, + ])); + + $dept2 = $database->createDocument('departments_nested', new Document([ + '$id' => 'dept2', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Marketing', + 'budget' => 50000, + ])); + + $company1 = $database->createDocument('companies_nested', new Document([ + '$id' => 'company1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'TechCorp', + 'industry' => 'Technology', + ])); + + $company2 = $database->createDocument('companies_nested', new Document([ + '$id' => 'company2', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'MarketCo', + 'industry' => 'Marketing', + ])); + + $employee1 = $database->createDocument('employees_nested', new Document([ + '$id' => 'emp1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Alice Johnson', + 'role' => 'Developer', + 'company' => 'company1', + 'department' => 'dept1', + ])); + + $employee2 = $database->createDocument('employees_nested', new Document([ + '$id' => 'emp2', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Bob Smith', + 'role' => 'Marketer', + 'company' => 'company2', + 'department' => 'dept2', + ])); + + $project1 = $database->createDocument('projects_nested', new Document([ + '$id' => 'proj1', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Website Redesign', + 'status' => 'active', + 'employee' => 'emp1', + ])); + + $project2 = $database->createDocument('projects_nested', new Document([ + '$id' => 'proj2', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Campaign Launch', + 'status' => 'planning', + 'employee' => 'emp2', + ])); + + $task1 = $database->createDocument('tasks_nested', new Document([ + '$id' => 'task1', + '$permissions' => [Permission::read(Role::any())], + 'description' => 'Design homepage', + 'priority' => 'high', + 'completed' => false, + 'project' => 'proj1', + ])); + + $task2 = $database->createDocument('tasks_nested', new Document([ + '$id' => 'task2', + '$permissions' => [Permission::read(Role::any())], + 'description' => 'Write copy', + 'priority' => 'medium', + 'completed' => true, + 'project' => 'proj2', + ])); + + $task3 = $database->createDocument('tasks_nested', new Document([ + '$id' => 'task3', + '$permissions' => [Permission::read(Role::any())], + 'description' => 'Implement backend', + 'priority' => 'high', + 'completed' => false, + 'project' => 'proj1', + ])); + + // ==================== DEPTH 1 TESTS ==================== + // Test: Query employees by company name (1 level deep) + $employees = $database->find('employees_nested', [ + Query::equal('company.name', ['TechCorp']), + ]); + $this->assertCount(1, $employees); + $this->assertEquals('emp1', $employees[0]->getId()); + + // Test: Query employees by department name (1 level deep MANY_TO_ONE) + $employees = $database->find('employees_nested', [ + Query::equal('department.name', ['Engineering']), + ]); + $this->assertCount(1, $employees); + $this->assertEquals('emp1', $employees[0]->getId()); + + // Test: Query projects by employee name (1 level deep) + $projects = $database->find('projects_nested', [ + Query::equal('employee.name', ['Alice Johnson']), + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj1', $projects[0]->getId()); + + // ==================== DEPTH 2 TESTS ==================== + // Test: Query projects by employee's company name (2 levels deep) + $projects = $database->find('projects_nested', [ + Query::equal('employee.company.name', ['TechCorp']), + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj1', $projects[0]->getId()); + + // Test: Query projects by employee's department name (2 levels deep, MANY_TO_ONE) + $projects = $database->find('projects_nested', [ + Query::equal('employee.department.name', ['Engineering']), + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj1', $projects[0]->getId()); + + // Test: Query tasks by project employee name (2 levels deep) + $tasks = $database->find('tasks_nested', [ + Query::equal('project.employee.name', ['Alice Johnson']), + ]); + $this->assertCount(2, $tasks); + + // ==================== DEPTH 3 TESTS ==================== + // Test: Query tasks by project->employee->company name (3 levels deep) + $tasks = $database->find('tasks_nested', [ + Query::equal('project.employee.company.name', ['TechCorp']), + ]); + $this->assertCount(2, $tasks); + $this->assertEquals('task1', $tasks[0]->getId()); + $this->assertEquals('task3', $tasks[1]->getId()); + + // Test: Query tasks by project->employee->department budget (3 levels deep with MANY_TO_ONE) + $tasks = $database->find('tasks_nested', [ + Query::greaterThan('project.employee.department.budget', 75000), + ]); + $this->assertCount(2, $tasks); // Both tasks are in projects by employees in Engineering dept + + // Test: Query tasks by project->employee->company industry (3 levels deep) + $tasks = $database->find('tasks_nested', [ + Query::equal('project.employee.company.industry', ['Marketing']), + ]); + $this->assertCount(1, $tasks); + $this->assertEquals('task2', $tasks[0]->getId()); + + // ==================== COMBINED DEPTH TESTS ==================== + // Test: Combine depth 1 and depth 3 queries + $tasks = $database->find('tasks_nested', [ + Query::equal('priority', ['high']), + Query::equal('project.employee.company.name', ['TechCorp']), + ]); + $this->assertCount(2, $tasks); + + // Test: Multiple depth 2 queries combined + $projects = $database->find('projects_nested', [ + Query::equal('employee.company.industry', ['Technology']), + Query::equal('employee.department.name', ['Engineering']), + ]); + $this->assertCount(1, $projects); + $this->assertEquals('proj1', $projects[0]->getId()); + + // Clean up + $database->deleteCollection('tasks_nested'); + $database->deleteCollection('projects_nested'); + $database->deleteCollection('employees_nested'); + $database->deleteCollection('departments_nested'); + $database->deleteCollection('companies_nested'); + } + + public function testCountAndSumWithRelationshipQueries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Create Author -> Posts relationship with view count + $database->createCollection('authors_count'); + $database->createCollection('posts_count'); + + $database->createAttribute('authors_count', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('authors_count', 'age', Database::VAR_INTEGER, 0, true); + $database->createAttribute('posts_count', 'title', Database::VAR_STRING, 255, true); + $database->createAttribute('posts_count', 'views', Database::VAR_INTEGER, 0, true); + $database->createAttribute('posts_count', 'published', Database::VAR_BOOLEAN, 0, true); + + $database->createRelationship( + collection: 'authors_count', + relatedCollection: 'posts_count', + type: Database::RELATION_ONE_TO_MANY, + twoWay: true, + id: 'posts', + twoWayKey: 'author' + ); + + // Create test data + $author1 = $database->createDocument('authors_count', new Document([ + '$id' => 'author1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Alice', + 'age' => 30, + ])); + + $author2 = $database->createDocument('authors_count', new Document([ + '$id' => 'author2', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Bob', + 'age' => 25, + ])); + + $author3 = $database->createDocument('authors_count', new Document([ + '$id' => 'author3', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Charlie', + 'age' => 35, + ])); + + // Create posts + $database->createDocument('posts_count', new Document([ + '$id' => 'post1', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 1', + 'views' => 100, + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_count', new Document([ + '$id' => 'post2', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Post 2', + 'views' => 200, + 'published' => true, + 'author' => 'author1', + ])); + + $database->createDocument('posts_count', new Document([ + '$id' => 'post3', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Alice Draft', + 'views' => 50, + 'published' => false, + 'author' => 'author1', + ])); + + $database->createDocument('posts_count', new Document([ + '$id' => 'post4', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Bob Post', + 'views' => 150, + 'published' => true, + 'author' => 'author2', + ])); + + $database->createDocument('posts_count', new Document([ + '$id' => 'post5', + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Bob Draft', + 'views' => 75, + 'published' => false, + 'author' => 'author2', + ])); + + // Test: Count posts by author name + $count = $database->count('posts_count', [ + Query::equal('author.name', ['Alice']), + ]); + $this->assertEquals(3, $count); + + // Test: Count published posts by author age filter + $count = $database->count('posts_count', [ + Query::lessThan('author.age', 30), + Query::equal('published', [true]), + ]); + $this->assertEquals(1, $count); // Only Bob's published post + + // Test: Count posts by author name (different author) + $count = $database->count('posts_count', [ + Query::equal('author.name', ['Bob']), + ]); + $this->assertEquals(2, $count); + + // Test: Count with no matches (author with no posts) + $count = $database->count('posts_count', [ + Query::equal('author.name', ['Charlie']), + ]); + $this->assertEquals(0, $count); + + // Test: Sum views for posts by author name + $sum = $database->sum('posts_count', 'views', [ + Query::equal('author.name', ['Alice']), + ]); + $this->assertEquals(350, $sum); // 100 + 200 + 50 + + // Test: Sum views for published posts by author age + $sum = $database->sum('posts_count', 'views', [ + Query::lessThan('author.age', 30), + Query::equal('published', [true]), + ]); + $this->assertEquals(150, $sum); // Only Bob's published post + + // Test: Sum views for Bob's posts + $sum = $database->sum('posts_count', 'views', [ + Query::equal('author.name', ['Bob']), + ]); + $this->assertEquals(225, $sum); // 150 + 75 + + // Test: Sum with no matches + $sum = $database->sum('posts_count', 'views', [ + Query::equal('author.name', ['Charlie']), + ]); + $this->assertEquals(0, $sum); + + // Clean up + $database->deleteCollection('authors_count'); + $database->deleteCollection('posts_count'); + } } diff --git a/tests/unit/Validator/Query/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index ff7bd2630..a0ec65eeb 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -6,12 +6,11 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; -use Utopia\Database\Validator\Query\Base; use Utopia\Database\Validator\Query\Filter; class FilterTest extends TestCase { - protected Base|null $validator = null; + protected Filter|null $validator = null; /** * @throws \Utopia\Database\Exception @@ -45,7 +44,10 @@ public function setUp(): void ]), ]; - $this->validator = new Filter($attributes, Database::VAR_INTEGER); + $this->validator = new Filter( + $attributes, + Database::VAR_INTEGER + ); } public function testSuccess(): void @@ -106,13 +108,14 @@ public function testEmptyValues(): void public function testMaxValuesCount(): void { + $max = $this->validator->getMaxValuesCount(); $values = []; - for ($i = 1; $i <= 200; $i++) { + for ($i = 1; $i <= $max + 1; $i++) { $values[] = $i; } $this->assertFalse($this->validator->isValid(Query::equal('integer', $values))); - $this->assertEquals('Query on attribute has greater than 100 values: integer', $this->validator->getDescription()); + $this->assertEquals('Query on attribute has greater than '.$max.' values: integer', $this->validator->getDescription()); } public function testNotContains(): void
{{ queries[n-1] }}
1 role100 roles500 roles1000 roles2000 roles + {{ set.roles }} {{ set.roles === 1 ? 'role' : 'roles' }} +