diff --git a/.gitignore b/.gitignore index 8f1497051..46daf3d31 100755 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,9 @@ mock.json data-tests.php loader.php .phpunit.result.cache -/bin/view/results/ .vscode .vscode/* database.sql - -## - Oh Wess! Makefile .envrc .vscode diff --git a/bin/cli.php b/bin/cli.php index bbb60df5a..d9932d0b2 100644 --- a/bin/cli.php +++ b/bin/cli.php @@ -1,10 +1,11 @@ task('index') ->desc('Index mock data for testing queries') - ->param('adapter', '', new Text(0), 'Database adapter', false) - ->param('name', '', new Text(0), 'Name of created database.', false) + ->param('adapter', '', new Text(0), 'Database adapter') + ->param('name', '', new Text(0), 'Name of created database.') ->action(function ($adapter, $name) { $namespace = '_ns'; $cache = new Cache(new NoCache()); @@ -74,36 +74,36 @@ $database->setDatabase($name); $database->setNamespace($namespace); - Console::info("For query: greaterThan(created, 2010-01-01 05:00:00)', 'equal(genre,travel)"); - + Console::info("greaterThan('created', ['2010-01-01 05:00:00']), equal('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::info("equal('genre', ['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::info("greaterThan('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::info("search('text', 'Alice')"); $start = microtime(true); $database->createIndex('articles', 'fulltextsearch', Database::INDEX_FULLTEXT, ['text']); $time = microtime(true) - $start; Console::success("{$time} seconds"); - }); + Console::info("contains('tags', ['tag1'])"); + $start = microtime(true); + $database->createIndex('articles', 'tags', Database::INDEX_KEY, ['tags']); + $time = microtime(true) - $start; + Console::success("{$time} seconds"); + }); $cli ->error() diff --git a/bin/tasks/load.php b/bin/tasks/load.php index 4658eadd6..a094292b3 100644 --- a/bin/tasks/load.php +++ b/bin/tasks/load.php @@ -27,7 +27,7 @@ /** * @Example - * docker-compose exec tests bin/load --adapter=mariadb --limit=1000 --name=testing + * docker compose exec tests bin/load --adapter=mariadb --limit=1000 --name=testing */ $cli @@ -44,6 +44,7 @@ Console::info("Filling {$adapter} with {$limit} records: {$name}"); Swoole\Runtime::enableCoroutine(); + switch ($adapter) { case 'mariadb': Co\run(function () use (&$start, $limit, $name, $namespace, $cache) { @@ -85,7 +86,7 @@ // A coroutine is assigned per 1000 documents for ($i = 0; $i < $limit / 1000; $i++) { - go(function () use ($pool, $faker, $name, $cache, $namespace) { + \go(function () use ($pool, $faker, $name, $cache, $namespace) { $pdo = $pool->get(); $database = new Database(new MariaDB($pdo), $cache); @@ -94,7 +95,7 @@ // Each coroutine loads 1000 documents for ($i = 0; $i < 1000; $i++) { - addArticle($database, $faker); + createDocument($database, $faker); } // Reclaim resources @@ -146,7 +147,7 @@ // A coroutine is assigned per 1000 documents for ($i = 0; $i < $limit / 1000; $i++) { - go(function () use ($pool, $faker, $name, $cache, $namespace) { + \go(function () use ($pool, $faker, $name, $cache, $namespace) { $pdo = $pool->get(); $database = new Database(new MySQL($pdo), $cache); @@ -155,7 +156,7 @@ // Each coroutine loads 1000 documents for ($i = 0; $i < 1000; $i++) { - addArticle($database, $faker); + createDocument($database, $faker); } // Reclaim resources @@ -197,7 +198,7 @@ // Each coroutine loads 1000 documents for ($i = 0; $i < 1000; $i++) { - addArticle($database, $faker); + createDocument($database, $faker); } $database = null; @@ -233,25 +234,26 @@ function createSchema(Database $database): void $database->create(); Authorization::setRole(Role::any()->toString()); + $database->createCollection('articles', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), ]); $database->createAttribute('articles', 'author', Database::VAR_STRING, 256, true); - $database->createAttribute('articles', 'created', Database::VAR_DATETIME, 0, true, null, false, false, null, [], ['datetime']); + $database->createAttribute('articles', 'created', Database::VAR_DATETIME, 0, true, filters: ['datetime']); $database->createAttribute('articles', 'text', Database::VAR_STRING, 5000, true); $database->createAttribute('articles', 'genre', Database::VAR_STRING, 256, true); $database->createAttribute('articles', 'views', Database::VAR_INTEGER, 0, true); + $database->createAttribute('articles', 'tags', Database::VAR_STRING, 0, true, array: true); $database->createIndex('articles', 'text', Database::INDEX_FULLTEXT, ['text']); } -function addArticle($database, Generator $faker): void +function createDocument($database, Generator $faker): 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))), @@ -272,6 +274,7 @@ function addArticle($database, Generator $faker): void '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) + 'views' => $faker->randomNumber(6), + 'tags' => $faker->randomElements(['short', 'quick', 'easy', 'medium', 'hard'], $faker->numberBetween(1, 5)), ])); } diff --git a/bin/tasks/query.php b/bin/tasks/query.php index 2fcd29143..4ece99e89 100644 --- a/bin/tasks/query.php +++ b/bin/tasks/query.php @@ -21,13 +21,13 @@ /** * @Example - * docker-compose exec tests bin/query --adapter=mariadb --limit=1000 --name=testing + * docker compose exec tests bin/query --adapter=mariadb --limit=1000 --name=testing */ $cli ->task('query') ->desc('Query mock data') - ->param('adapter', '', new Text(0), 'Database adapter', false) - ->param('name', '', new Text(0), 'Name of created database.', false) + ->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) { $namespace = '_ns'; @@ -80,40 +80,39 @@ return; } - $faker = Factory::create(); $report = []; - $count = addRoles($faker, 1); + $count = setRoles($faker, 1); Console::info("\n{$count} roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; - $count = addRoles($faker, 100); + $count = setRoles($faker, 100); Console::info("\n{$count} roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; - $count = addRoles($faker, 400); + $count = setRoles($faker, 400); Console::info("\n{$count} roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; - $count = addRoles($faker, 500); + $count = setRoles($faker, 500); Console::info("\n{$count} roles:"); $report[] = [ 'roles' => $count, 'results' => runQueries($database, $limit) ]; - $count = addRoles($faker, 1000); + $count = setRoles($faker, 1000); Console::info("\n{$count} roles:"); $report[] = [ 'roles' => $count, @@ -121,72 +120,72 @@ ]; if (!file_exists('bin/view/results')) { - mkdir('bin/view/results', 0777, true); + \mkdir('bin/view/results', 0777, true); } - $time = time(); - $f = fopen("bin/view/results/{$adapter}_{$name}_{$limit}_{$time}.json", 'w'); - fwrite($f, json_encode($report)); - fclose($f); + $time = \time(); + $results = \fopen("bin/view/results/{$adapter}_{$name}_{$limit}_{$time}.json", 'w'); + \fwrite($results, \json_encode($report)); + \fclose($results); }); - $cli -->error() -->inject('error') -->action(function (Exception $error) { - Console::error($error->getMessage()); -}); + ->error() + ->inject('error') + ->action(function (Exception $error) { + Console::error($error->getMessage()); + }); +function setRoles($faker, $count): int +{ + for ($i = 0; $i < $count; $i++) { + Authorization::setRole($faker->numerify('user####')); + } + return \count(Authorization::getRoles()); +} -function runQueries(Database $database, int $limit) +function runQueries(Database $database, int $limit): array { $results = []; - // Recent travel blogs - $results[] = runQuery([ + // Recent travel blogs + $results["Querying greater than, equal[1] and limit"] = runQuery([ Query::greaterThan('created', '2010-01-01 05:00:00'), Query::equal('genre', ['travel']), Query::limit($limit) ], $database); // Favorite genres - - $results[] = runQuery([ + $results["Querying equal[3] and limit"] = runQuery([ Query::equal('genre', ['fashion', 'finance', 'sports']), Query::limit($limit) ], $database); // Popular posts - - $results[] = runQuery([ + $results["Querying greaterThan, limit({$limit})"] = runQuery([ Query::greaterThan('views', 100000), Query::limit($limit) ], $database); // Fulltext search - - $results[] = runQuery([ + $results["Query search, limit({$limit})"] = runQuery([ Query::search('text', 'Alice'), Query::limit($limit) ], $database); - return $results; -} + // Tags contain query + $results["Querying contains[1], limit({$limit})"] = runQuery([ + Query::contains('tags', ['tag1']), + Query::limit($limit) + ], $database); -function addRoles($faker, $count) -{ - for ($i = 0; $i < $count; $i++) { - Authorization::setRole($faker->numerify('user####')); - } - return count(Authorization::getRoles()); + return $results; } function runQuery(array $query, Database $database) { - $info = array_map(function ($q) { - /** @var $q Query */ - return $q->getAttribute() . ' : ' . $q->getMethod() . ' : ' . implode(',', $q->getValues()); + $info = array_map(function (Query $q) { + return $q->getAttribute() . ': ' . $q->getMethod() . ' = ' . implode(',', $q->getValues()); }, $query); Console::log('Running query: [' . implode(', ', $info) . ']'); diff --git a/bin/view/results/mariadb_myapp_642ba64fe5e9f_25_1680582832.json b/bin/view/results/mariadb_myapp_642ba64fe5e9f_25_1680582832.json new file mode 100644 index 000000000..a465c3af4 --- /dev/null +++ b/bin/view/results/mariadb_myapp_642ba64fe5e9f_25_1680582832.json @@ -0,0 +1 @@ +[{"roles":2,"results":[0.004142045974731445,0.0007698535919189453,0.0006570816040039062,4.3037660121917725]},{"roles":102,"results":[0.0031499862670898438,0.0012857913970947266,0.0013210773468017578,4.130218029022217]},{"roles":491,"results":[0.4965331554412842,0.36292195320129395,0.30788612365722656,4.9501330852508545]},{"roles":964,"results":[0.44646310806274414,0.44009995460510254,0.37430691719055176,4.239892959594727]},{"roles":1829,"results":[0.6837189197540283,1.6691820621490479,1.3487520217895508,98.95817399024963]}] \ No newline at end of file diff --git a/bin/view/results/mariadb_myapp_642baa6d33383_25_1680583630.json b/bin/view/results/mariadb_myapp_642baa6d33383_25_1680583630.json new file mode 100644 index 000000000..6cbfa187b --- /dev/null +++ b/bin/view/results/mariadb_myapp_642baa6d33383_25_1680583630.json @@ -0,0 +1,47 @@ +[ + { + "roles": 2, + "results": [ + 0.004247903823852539, + 0.0007619857788085938, + 0.0008020401000976562, + 4.970219850540161 + ] + }, + { + "roles": 101, + "results": [ + 0.0033349990844726562, + 0.001294851303100586, + 0.001383066177368164, + 4.7076640129089355 + ] + }, + { + "roles": 485, + "results": [ + 0.4268150329589844, + 0.32375311851501465, + 0.35645008087158203, + 4.3803019523620605 + ] + }, + { + "roles": 946, + "results": [ + 0.4152810573577881, + 0.4178469181060791, + 0.4439430236816406, + 4.6542909145355225 + ] + }, + { + "roles": 1809, + "results": [ + 0.7865281105041504, + 1.7059669494628906, + 1.4522700309753418, + 98.20597195625305 + ] + } +] \ No newline at end of file diff --git a/bin/view/results/mariadb_testing_1000_1697771040.json b/bin/view/results/mariadb_testing_1000_1697771040.json new file mode 100644 index 000000000..d08e1ca68 --- /dev/null +++ b/bin/view/results/mariadb_testing_1000_1697771040.json @@ -0,0 +1,52 @@ +[ + { + "roles": 2, + "results": { + "greaterThan, equal[1], limit(1000)": 0.014531135559082031, + "equal[3], limit(1000)": 0.01854395866394043, + "greaterThan, limit(1000)": 0.03291916847229004, + "search, limit(1000)": 0.037921905517578125, + "contains[1], limit(1000)": 0.0019600391387939453 + } + }, + { + "roles": 102, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0018169879913330078, + "equal[3], limit(1000)": 0.012106895446777344, + "greaterThan, limit(1000)": 0.030903100967407227, + "search, limit(1000)": 0.03960585594177246, + "contains[1], limit(1000)": 0.0017769336700439453 + } + }, + { + "roles": 495, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0020799636840820312, + "equal[3], limit(1000)": 0.013564109802246094, + "greaterThan, limit(1000)": 0.032176971435546875, + "search, limit(1000)": 0.03503084182739258, + "contains[1], limit(1000)": 0.001474142074584961 + } + }, + { + "roles": 954, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0017261505126953125, + "equal[3], limit(1000)": 0.012243986129760742, + "greaterThan, limit(1000)": 0.03142595291137695, + "search, limit(1000)": 0.03658008575439453, + "contains[1], limit(1000)": 0.0016679763793945312 + } + }, + { + "roles": 1812, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0019099712371826172, + "equal[3], limit(1000)": 0.012614965438842773, + "greaterThan, limit(1000)": 0.030133962631225586, + "search, limit(1000)": 0.03749680519104004, + "contains[1], limit(1000)": 0.0017859935760498047 + } + } +] \ No newline at end of file diff --git a/bin/view/results/mariadb_testing_1000_1697773496.json b/bin/view/results/mariadb_testing_1000_1697773496.json new file mode 100644 index 000000000..6567e877a --- /dev/null +++ b/bin/view/results/mariadb_testing_1000_1697773496.json @@ -0,0 +1,47 @@ +[ + { + "roles": 2, + "results": { + "greaterThan, equal[1], limit(1000)": 0.017921924591064453, + "equal[3], limit(1000)": 0.018985986709594727, + "greaterThan, limit(1000)": 0.03374195098876953, + "contains[1], limit(1000)": 0.001703023910522461 + } + }, + { + "roles": 101, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0020508766174316406, + "equal[3], limit(1000)": 0.012971878051757812, + "greaterThan, limit(1000)": 0.032111167907714844, + "contains[1], limit(1000)": 0.0015919208526611328 + } + }, + { + "roles": 490, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0020859241485595703, + "equal[3], limit(1000)": 0.013467073440551758, + "greaterThan, limit(1000)": 0.032073974609375, + "contains[1], limit(1000)": 0.0016400814056396484 + } + }, + { + "roles": 946, + "results": { + "greaterThan, equal[1], limit(1000)": 0.002042055130004883, + "equal[3], limit(1000)": 0.013000011444091797, + "greaterThan, limit(1000)": 0.03235602378845215, + "contains[1], limit(1000)": 0.0015759468078613281 + } + }, + { + "roles": 1814, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0020787715911865234, + "equal[3], limit(1000)": 0.01301884651184082, + "greaterThan, limit(1000)": 0.030966997146606445, + "contains[1], limit(1000)": 0.001605987548828125 + } + } +] \ No newline at end of file diff --git a/bin/view/results/mariadb_testing_1000_1697773900.json b/bin/view/results/mariadb_testing_1000_1697773900.json new file mode 100644 index 000000000..8ea5ed6af --- /dev/null +++ b/bin/view/results/mariadb_testing_1000_1697773900.json @@ -0,0 +1,52 @@ +[ + { + "roles": 2, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0120849609375, + "equal[3], limit(1000)": 0.015228033065795898, + "greaterThan, limit(1000)": 0.03383207321166992, + "search, limit(1000)": 0.040261030197143555, + "contains[1], limit(1000)": 0.0017671585083007812 + } + }, + { + "roles": 101, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0018150806427001953, + "equal[3], limit(1000)": 0.01302790641784668, + "greaterThan, limit(1000)": 0.03197622299194336, + "search, limit(1000)": 0.03850388526916504, + "contains[1], limit(1000)": 0.0015590190887451172 + } + }, + { + "roles": 491, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0018579959869384766, + "equal[3], limit(1000)": 0.013176202774047852, + "greaterThan, limit(1000)": 0.03150510787963867, + "search, limit(1000)": 0.03767108917236328, + "contains[1], limit(1000)": 0.0016279220581054688 + } + }, + { + "roles": 952, + "results": { + "greaterThan, equal[1], limit(1000)": 0.001962900161743164, + "equal[3], limit(1000)": 0.013421058654785156, + "greaterThan, limit(1000)": 0.03141212463378906, + "search, limit(1000)": 0.03910017013549805, + "contains[1], limit(1000)": 0.0019600391387939453 + } + }, + { + "roles": 1825, + "results": { + "greaterThan, equal[1], limit(1000)": 0.0020799636840820312, + "equal[3], limit(1000)": 0.014293909072875977, + "greaterThan, limit(1000)": 0.0318300724029541, + "search, limit(1000)": 0.0378110408782959, + "contains[1], limit(1000)": 0.001756906509399414 + } + } +] \ No newline at end of file diff --git a/bin/view/results/mariadb_testing_1000_1697773977.json b/bin/view/results/mariadb_testing_1000_1697773977.json new file mode 100644 index 000000000..d19acc1a3 --- /dev/null +++ b/bin/view/results/mariadb_testing_1000_1697773977.json @@ -0,0 +1 @@ +[{"roles":2,"results":{"greaterThan, equal[1], limit(1000)":0.011281013488769531,"equal[3], limit(1000)":0.018298864364624023,"greaterThan, limit(1000)":0.03335213661193848,"search, limit(1000)":0.03667807579040527,"contains[1], limit(1000)":0.0016739368438720703}},{"roles":102,"results":{"greaterThan, equal[1], limit(1000)":0.001981973648071289,"equal[3], limit(1000)":0.012470006942749023,"greaterThan, limit(1000)":0.029846906661987305,"search, limit(1000)":0.036875009536743164,"contains[1], limit(1000)":0.001753091812133789}},{"roles":489,"results":{"greaterThan, equal[1], limit(1000)":0.0018579959869384766,"equal[3], limit(1000)":0.012539863586425781,"greaterThan, limit(1000)":0.03027510643005371,"search, limit(1000)":0.036364078521728516,"contains[1], limit(1000)":0.0017800331115722656}},{"roles":945,"results":{"greaterThan, equal[1], limit(1000)":0.0018270015716552734,"equal[3], limit(1000)":0.012778997421264648,"greaterThan, limit(1000)":0.0295259952545166,"search, limit(1000)":0.03641104698181152,"contains[1], limit(1000)":0.0015921592712402344}},{"roles":1811,"results":{"greaterThan, equal[1], limit(1000)":0.0018990039825439453,"equal[3], limit(1000)":0.012309074401855469,"greaterThan, limit(1000)":0.029526948928833008,"search, limit(1000)":0.03502988815307617,"contains[1], limit(1000)":0.0015959739685058594}}] \ No newline at end of file diff --git a/composer.lock b/composer.lock index a37087417..122a03843 100644 --- a/composer.lock +++ b/composer.lock @@ -269,16 +269,16 @@ }, { "name": "utopia-php/framework", - "version": "0.32.0", + "version": "0.33.1", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "ad6f7e6d6b38cf5bed4e3af9a1394c59d4bb9225" + "reference": "b745607aa1875554a0ad52e28f6db918da1ce11c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/ad6f7e6d6b38cf5bed4e3af9a1394c59d4bb9225", - "reference": "ad6f7e6d6b38cf5bed4e3af9a1394c59d4bb9225", + "url": "https://api.github.com/repos/utopia-php/http/zipball/b745607aa1875554a0ad52e28f6db918da1ce11c", + "reference": "b745607aa1875554a0ad52e28f6db918da1ce11c", "shasum": "" }, "require": { @@ -308,9 +308,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.32.0" + "source": "https://github.com/utopia-php/http/tree/0.33.1" }, - "time": "2023-12-26T14:18:36+00:00" + "time": "2024-01-17T16:48:32+00:00" }, { "name": "utopia-php/mongo", @@ -509,16 +509,16 @@ }, { "name": "laravel/pint", - "version": "v1.13.7", + "version": "v1.13.9", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "4157768980dbd977f1c4b4cc94997416d8b30ece" + "reference": "e3e269cc5d874c8efd2dc7962b1c7ff2585fe525" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/4157768980dbd977f1c4b4cc94997416d8b30ece", - "reference": "4157768980dbd977f1c4b4cc94997416d8b30ece", + "url": "https://api.github.com/repos/laravel/pint/zipball/e3e269cc5d874c8efd2dc7962b1c7ff2585fe525", + "reference": "e3e269cc5d874c8efd2dc7962b1c7ff2585fe525", "shasum": "" }, "require": { @@ -529,13 +529,13 @@ "php": "^8.1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.38.0", - "illuminate/view": "^10.30.1", + "friendsofphp/php-cs-fixer": "^3.47.0", + "illuminate/view": "^10.40.0", + "larastan/larastan": "^2.8.1", "laravel-zero/framework": "^10.3.0", - "mockery/mockery": "^1.6.6", - "nunomaduro/larastan": "^2.6.4", + "mockery/mockery": "^1.6.7", "nunomaduro/termwind": "^1.15.1", - "pestphp/pest": "^2.24.2" + "pestphp/pest": "^2.31.0" }, "bin": [ "builds/pint" @@ -571,7 +571,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2023-12-05T19:43:12+00:00" + "time": "2024-01-16T17:39:29+00:00" }, { "name": "myclabs/deep-copy", @@ -835,16 +835,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.54", + "version": "1.10.56", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "3e25f279dada0adc14ffd7bad09af2e2fc3523bb" + "reference": "27816a01aea996191ee14d010f325434c0ee76fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/3e25f279dada0adc14ffd7bad09af2e2fc3523bb", - "reference": "3e25f279dada0adc14ffd7bad09af2e2fc3523bb", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/27816a01aea996191ee14d010f325434c0ee76fa", + "reference": "27816a01aea996191ee14d010f325434c0ee76fa", "shasum": "" }, "require": { @@ -893,7 +893,7 @@ "type": "tidelift" } ], - "time": "2024-01-05T15:50:47+00:00" + "time": "2024-01-15T10:43:00+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2604,5 +2604,5 @@ "php": ">=8.0" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 8b386c771..10748489f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: POSTGRES_PASSWORD: password mariadb: - image: mariadb:10.7 + image: mariadb:10.11 container_name: utopia-mariadb networks: - database diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 0efa96df8..475665cd2 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -83,11 +83,13 @@ public function createCollection(string $name, array $attributes = [], array $in foreach ($attributes as $key => $attribute) { $attrId = $this->filter($attribute->getId()); - $attrType = $this->getSQLType($attribute->getAttribute('type'), $attribute->getAttribute('size', 0), $attribute->getAttribute('signed', true)); - if ($attribute->getAttribute('array')) { - $attrType = 'LONGTEXT'; - } + $attrType = $this->getSQLType( + $attribute->getAttribute('type'), + $attribute->getAttribute('size', 0), + $attribute->getAttribute('signed', true), + $attribute->getAttribute('array', false) + ); $attributeStrings[$key] = "`{$attrId}` {$attrType}, "; } @@ -268,11 +270,7 @@ public function createAttribute(string $collection, string $id, string $type, in { $name = $this->filter($collection); $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed); - - if ($array) { - $type = 'LONGTEXT'; - } + $type = $this->getSQLType($type, $size, $signed, $array); $sql = "ALTER TABLE {$this->getSQLTable($name)} ADD COLUMN `{$id}` {$type};"; $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); @@ -299,11 +297,7 @@ public function updateAttribute(string $collection, string $id, string $type, in { $name = $this->filter($collection); $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed); - - if ($array) { - $type = 'LONGTEXT'; - } + $type = $this->getSQLType($type, $size, $signed, $array); $sql = "ALTER TABLE {$this->getSQLTable($name)} MODIFY `{$id}` {$type};"; @@ -614,36 +608,55 @@ public function renameIndex(string $collection, string $old, string $new): bool * @param array $lengths * @param array $orders * @return bool - * @throws Exception - * @throws PDOException + * @throws DatabaseException */ public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders): bool { - $name = $this->filter($collection); + $collection = $this->getDocument(Database::METADATA, $collection); + + if ($collection->isEmpty()) { + throw new DatabaseException('Collection not found'); + } + + $collectionAttributes = \json_decode($collection->getAttribute('attributes', []), true); + $id = $this->filter($id); - $attributes = \array_map(fn ($attribute) => match ($attribute) { - '$id' => '_uid', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - default => $attribute - }, $attributes); + foreach ($attributes as $i => $attr) { + $collectionAttribute = \array_filter($collectionAttributes, fn ($collectionAttribute) => array_key_exists('key', $collectionAttribute) && $collectionAttribute['key'] === $attr); + $collectionAttribute = end($collectionAttribute); + $order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i]; + $length = empty($lengths[$i]) ? '' : '(' . (int)$lengths[$i] . ')'; + + $attr = match ($attr) { + '$id' => '_uid', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + default => $this->filter($attr), + }; - foreach ($attributes as $key => $attribute) { - $length = $lengths[$key] ?? ''; - $length = (empty($length)) ? '' : '(' . (int)$length . ')'; - $order = $orders[$key] ?? ''; - $attribute = $this->filter($attribute); + $attributes[$i] = "`{$attr}`{$length} {$order}"; - if (Database::INDEX_FULLTEXT === $type) { - $order = ''; + if(!empty($collectionAttribute['array']) && $this->castIndexArray()) { + $attributes[$i] = '(CAST(' . $attr . ' AS char(' . Database::ARRAY_INDEX_LENGTH . ') ARRAY))'; } - - $attributes[$key] = "`{$attribute}`{$length} {$order}"; } - $sql = $this->getSQLIndex($name, $id, $type, $attributes); + $sqlType = match ($type) { + Database::INDEX_KEY => 'INDEX', + Database::INDEX_UNIQUE => 'UNIQUE INDEX', + Database::INDEX_FULLTEXT => 'FULLTEXT INDEX', + default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT), + }; + + $attributes = \implode(', ', $attributes); + if ($this->shareTables && $type !== Database::INDEX_FULLTEXT) { + // Add tenant as first index column for best performance + $attributes = "_tenant, {$attributes}"; + } + + $sql = "CREATE {$sqlType} `{$id}` ON {$this->getSQLTable($collection->getId())} ({$attributes})"; $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); return $this->getPDO() @@ -651,6 +664,14 @@ public function createIndex(string $collection, string $id, string $type, array ->execute(); } + /** + * @return bool + */ + public function castIndexArray(): bool + { + return false; + } + /** * Delete Index * @@ -1912,19 +1933,25 @@ protected function getSQLCondition(Query $query): string return empty($conditions) ? '' : ' '. $method .' (' . implode(' AND ', $conditions) . ')'; case Query::TYPE_SEARCH: - return "MATCH(table_main.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; + return "MATCH(`table_main`.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; case Query::TYPE_BETWEEN: - return "table_main.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + return "`table_main`.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: - return "table_main.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + return "`table_main`.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + + case Query::TYPE_CONTAINS: + if($this->getSupportForJSONOverlaps() && $query->onArray()) { + return "JSON_OVERLAPS(`table_main`.{$attribute}, :{$placeholder}_0)"; + } + // no break default: $conditions = []; foreach ($query->getValues() as $key => $value) { - $conditions[] = $attribute . ' ' . $this->getSQLOperator($query->getMethod()) . ' :' . $placeholder . '_' . $key; + $conditions[] = "{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; } return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; } @@ -1936,11 +1963,16 @@ protected function getSQLCondition(Query $query): string * @param string $type * @param int $size * @param bool $signed + * @param bool $array * @return string - * @throws Exception + * @throws DatabaseException */ - protected function getSQLType(string $type, int $size, bool $signed = true): string + protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false): string { + if($array === true) { + return 'JSON'; + } + switch ($type) { case Database::VAR_STRING: // $size = $size * 4; // Convert utf8mb4 size to bytes @@ -1985,36 +2017,6 @@ protected function getSQLType(string $type, int $size, bool $signed = true): str } } - /** - * Get SQL Index - * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @return string - * @throws Exception - */ - protected function getSQLIndex(string $collection, string $id, string $type, array $attributes): string - { - $sqlType = match ($type) { - Database::INDEX_KEY, - Database::INDEX_ARRAY => 'INDEX', - Database::INDEX_UNIQUE => 'UNIQUE INDEX', - Database::INDEX_FULLTEXT => 'FULLTEXT INDEX', - default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT), - }; - - $attributes = \implode(', ', $attributes); - - if ($this->shareTables && $type !== Database::INDEX_FULLTEXT) { - // Add tenant as first index column for best performance - $attributes = "_tenant, {$attributes}"; - } - - return "CREATE {$sqlType} `{$id}` ON {$this->getSQLTable($collection)} ({$attributes})"; - } - /** * Get PDO Type * @@ -2042,6 +2044,16 @@ public function getSupportForFulltextWildcardIndex(): bool return true; } + /** + * Does the adapter handle Query Array Overlaps? + * + * @return bool + */ + public function getSupportForJSONOverlaps(): bool + { + return true; + } + /** * Are timeouts supported? * diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index bf0971040..12cd7a9fa 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1388,7 +1388,11 @@ protected function buildFilter(Query $query): array } elseif ($operator == '$ne' && \is_array($value)) { $filter[$attribute]['$nin'] = $value; } elseif ($operator == '$in') { - $filter[$attribute]['$in'] = $query->getValues(); + if($query->getMethod() === Query::TYPE_CONTAINS && !$query->onArray()) { + $filter[$attribute]['$regex'] = new Regex(".*{$this->escapeWildcards($value)}.*", 'i'); + } else { + $filter[$attribute]['$in'] = $query->getValues(); + } } elseif ($operator == '$search') { $filter['$text'][$operator] = $value; } elseif ($operator === Query::TYPE_BETWEEN) { diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 23d98cadc..40e98ec97 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -9,47 +9,6 @@ class MySQL extends MariaDB { - /** - * Get SQL Index - * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * - * @return string - * @throws DatabaseException - */ - protected function getSQLIndex(string $collection, string $id, string $type, array $attributes): string - { - switch ($type) { - case Database::INDEX_KEY: - $type = 'INDEX'; - break; - - case Database::INDEX_ARRAY: - $type = 'INDEX'; - - foreach ($attributes as $key => $value) { - $attributes[$key] = '(CAST(' . $value . ' AS char(255) ARRAY))'; - } - break; - - case Database::INDEX_UNIQUE: - $type = 'UNIQUE INDEX'; - break; - - case Database::INDEX_FULLTEXT: - $type = 'FULLTEXT INDEX'; - break; - - default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT); - } - - return 'CREATE '.$type.' `'.$id.'` ON `'.$this->getDatabase().'`.`'.$this->getNamespace().'_'.$collection.'` ( '.implode(', ', $attributes).' );'; - } - /** * Set max execution time * @param int $milliseconds @@ -132,4 +91,12 @@ public function getSizeOfCollection(string $collection): int return $size; } + + /** + * @return bool + */ + public function castIndexArray(): bool + { + return true; + } } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index e52be9b53..149271128 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -84,20 +84,21 @@ public function createCollection(string $name, array $attributes = [], array $in $this->getPDO()->beginTransaction(); - foreach ($attributes as &$attribute) { + /** @var array $attributeStrings */ + $attributeStrings = []; + foreach ($attributes as $attribute) { $attrId = $this->filter($attribute->getId()); - $attrType = $this->getSQLType($attribute->getAttribute('type'), $attribute->getAttribute('size', 0), $attribute->getAttribute('signed', true)); - if ($attribute->getAttribute('array')) { - $attrType = 'TEXT'; - } + $attrType = $this->getSQLType( + $attribute->getAttribute('type'), + $attribute->getAttribute('size', 0), + $attribute->getAttribute('signed', true), + $attribute->getAttribute('array', false) + ); - $attribute = "\"{$attrId}\" {$attrType}, "; + $attributeStrings[] = "\"{$attrId}\" {$attrType}, "; } - /** - * @var array $attributes - */ $sql = " CREATE TABLE IF NOT EXISTS {$this->getSQLTable($id)} ( _id SERIAL NOT NULL, @@ -106,7 +107,7 @@ public function createCollection(string $name, array $attributes = [], array $in \"_createdAt\" TIMESTAMP(3) DEFAULT NULL, \"_updatedAt\" TIMESTAMP(3) DEFAULT NULL, _permissions TEXT DEFAULT NULL, - " . \implode(' ', $attributes) . " + " . \implode(' ', $attributeStrings) . " PRIMARY KEY (_id) ); "; @@ -169,7 +170,7 @@ public function createCollection(string $name, array $attributes = [], array $in foreach ($indexes as $index) { $indexId = $this->filter($index->getId()); $indexType = $index->getAttribute('type'); - $indexAttributes = $index->getAttribute('attributes'); + $indexAttributes = $index->getAttribute('attributes', []); $indexOrders = $index->getAttribute('orders', []); $this->createIndex( @@ -261,11 +262,7 @@ public function createAttribute(string $collection, string $id, string $type, in { $name = $this->filter($collection); $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed); - - if ($array) { - $type = 'TEXT'; - } + $type = $this->getSQLType($type, $size, $signed, $array); $sql = " ALTER TABLE {$this->getSQLTable($name)} @@ -350,11 +347,7 @@ public function updateAttribute(string $collection, string $id, string $type, in { $name = $this->filter($collection); $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed); - - if ($array) { - $type = 'LONGTEXT'; - } + $type = $this->getSQLType($type, $size, $signed, $array); if ($type == 'TIMESTAMP(3)') { $type = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')"; @@ -585,34 +578,42 @@ public function deleteRelationship( */ public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders): bool { - $name = $this->filter($collection); + $collection = $this->filter($collection); $id = $this->filter($id); - $attributes = \array_map(fn ($attribute) => match ($attribute) { - '$id' => '_uid', - '$createdAt' => '_createdAt', - '$updatedAt' => '_updatedAt', - default => $attribute - }, $attributes); - - foreach ($attributes as $key => &$attribute) { - $length = $lengths[$key] ?? ''; - $length = (empty($length)) ? '' : '(' . (int)$length . ')'; - $order = $orders[$key] ?? ''; - $attribute = $this->filter($attribute); + foreach ($attributes as $i => $attr) { + $order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i]; - if (Database::INDEX_FULLTEXT === $type) { - $order = ''; - } + $attr = match ($attr) { + '$id' => '_uid', + '$createdAt' => '_createdAt', + '$updatedAt' => '_updatedAt', + default => $this->filter($attr), + }; if (Database::INDEX_UNIQUE === $type) { - $attribute = "LOWER(\"{$attribute}\"::text) {$order}"; + $attributes[$i] = "LOWER(\"{$attr}\"::text) {$order}"; } else { - $attribute = "\"{$attribute}\" {$order}"; + $attributes[$i] = "\"{$attr}\" {$order}"; } } - $sql = $this->getSQLIndex($name, $id, $type, $attributes); + $sqlType = match ($type) { + Database::INDEX_KEY, + Database::INDEX_FULLTEXT => 'INDEX', + Database::INDEX_UNIQUE => 'UNIQUE INDEX', + default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT), + }; + + $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; + $attributes = \implode(', ', $attributes); + + if ($this->shareTables && $type !== Database::INDEX_FULLTEXT) { + // Add tenant as first index column for best performance + $attributes = "_tenant, {$attributes}"; + } + + $sql = "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)} ({$attributes});"; $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); return $this->getPDO() @@ -1879,8 +1880,9 @@ protected function getSQLCondition(Query $query): string default => $query->getAttribute() }); - $attribute = "\"{$query->getAttribute()}\"" ; + $attribute = "\"{$query->getAttribute()}\""; $placeholder = $this->getSQLPlaceholder($query); + $operator = null; switch ($query->getMethod()) { case Query::TYPE_SEARCH: @@ -1893,10 +1895,15 @@ protected function getSQLCondition(Query $query): string case Query::TYPE_IS_NOT_NULL: return "table_main.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + case Query::TYPE_CONTAINS: + $operator = $query->onArray() ? '@>' : null; + + // no break default: $conditions = []; + $operator = $operator ?? $this->getSQLOperator($query->getMethod()); foreach ($query->getValues() as $key => $value) { - $conditions[] = $attribute.' '.$this->getSQLOperator($query->getMethod()).' :'.$placeholder.'_'.$key; + $conditions[] = $attribute.' '.$operator.' :'.$placeholder.'_'.$key; } $condition = implode(' OR ', $conditions); return empty($condition) ? '' : '(' . $condition . ')'; @@ -1926,11 +1933,17 @@ protected function getFulltextValue(string $value): string * * @param string $type * @param int $size in chars - * + * @param bool $signed + * @param bool $array * @return string + * @throws DatabaseException */ - protected function getSQLType(string $type, int $size, bool $signed = true): string + protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false): string { + if($array === true) { + return 'JSONB'; + } + switch ($type) { case Database::VAR_STRING: // $size = $size * 4; // Convert utf8mb4 size to bytes @@ -1965,38 +1978,6 @@ protected function getSQLType(string $type, int $size, bool $signed = true): str } } - /** - * Get SQL Index - * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * - * @return string - * @throws Exception - */ - protected function getSQLIndex(string $collection, string $id, string $type, array $attributes): string - { - $sqlType = match ($type) { - Database::INDEX_KEY, - Database::INDEX_ARRAY, - Database::INDEX_FULLTEXT => 'INDEX', - Database::INDEX_UNIQUE => 'UNIQUE INDEX', - default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT), - }; - - $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; - $attributes = \implode(', ', $attributes); - - if ($this->shareTables && $type !== Database::INDEX_FULLTEXT) { - // Add tenant as first index column for best performance - $attributes = "_tenant, {$attributes}"; - } - - return "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)} ({$attributes});"; - } - /** * Get SQL schema * @@ -2099,6 +2080,16 @@ public function getSupportForTimeouts(): bool return true; } + /** + * Does the adapter handle Query Array Overlaps? + * + * @return bool + */ + public function getSupportForJSONOverlaps(): bool + { + return false; + } + /** * Returns Max Execution Time * @param int $milliseconds @@ -2141,4 +2132,12 @@ protected function processException(PDOException $e): void throw $e; } + + /** + * @return string + */ + public function getLikeOperator(): string + { + return 'ILIKE'; + } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 5eab3f3b4..00b6d5832 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -679,9 +679,16 @@ public function getSupportForCasting(): bool */ public function getSupportForQueryContains(): bool { - return false; + return true; } + /** + * Does the adapter handle array Overlaps? + * + * @return bool + */ + abstract public function getSupportForJSONOverlaps(): bool; + public function getSupportForRelationships(): bool { return true; @@ -706,15 +713,23 @@ protected function bindConditionValue(mixed $stmt, Query $query): void return; } + if($this->getSupportForJSONOverlaps() && $query->onArray() && $query->getMethod() == Query::TYPE_CONTAINS) { + $placeholder = $this->getSQLPlaceholder($query) . '_0'; + $stmt->bindValue($placeholder, json_encode($query->getValues()), PDO::PARAM_STR); + return; + } + foreach ($query->getValues() as $key => $value) { $value = match ($query->getMethod()) { Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), Query::TYPE_SEARCH => $this->getFulltextValue($value), + Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', default => $value }; - $placeholder = $this->getSQLPlaceholder($query).'_'.$key; + $placeholder = $this->getSQLPlaceholder($query) . '_' . $key; + $stmt->bindValue($placeholder, $value, $this->getPDOType($value)); } } @@ -775,7 +790,8 @@ protected function getSQLOperator(string $method): string return 'IS NOT NULL'; case Query::TYPE_STARTS_WITH: case Query::TYPE_ENDS_WITH: - return 'LIKE'; + case Query::TYPE_CONTAINS: + return $this->getLikeOperator(); default: throw new DatabaseException('Unknown method: ' . $method); } @@ -819,7 +835,6 @@ protected function getSQLIndexType(string $type): string { switch ($type) { case Database::INDEX_KEY: - case Database::INDEX_ARRAY: return 'INDEX'; case Database::INDEX_UNIQUE: @@ -829,7 +844,7 @@ protected function getSQLIndexType(string $type): string return 'FULLTEXT INDEX'; default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT); + throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT); } } @@ -864,10 +879,11 @@ protected function getSQLPermissionsCondition(string $collection, array $roles): * * @param string $name * @return string + * @throws DatabaseException */ protected function getSQLTable(string $name): string { - return "`{$this->getDatabase()}`.`{$this->getNamespace()}_{$name}`"; + return "`{$this->getDatabase()}`.`{$this->getNamespace()}_{$this->filter($name)}`"; } /** @@ -943,7 +959,6 @@ public function getSQLConditions(array $queries = [], string $separator = 'AND') continue; } - /* @var $query Query */ if($query->isNested()) { $conditions[] = $this->getSQLConditions($query->getValues(), $query->getMethod()); } else { @@ -954,4 +969,13 @@ public function getSQLConditions(array $queries = [], string $separator = 'AND') $tmp = implode(' '. $separator .' ', $conditions); return empty($tmp) ? '' : '(' . $tmp . ')'; } + + /** + * @return string + */ + public function getLikeOperator(): string + { + return 'LIKE'; + } + } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 5e9cffd61..cb67f407b 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -124,13 +124,10 @@ public function createCollection(string $name, array $attributes = [], array $in $attrType = $this->getSQLType( $attribute->getAttribute('type'), $attribute->getAttribute('size', 0), - $attribute->getAttribute('signed', true) + $attribute->getAttribute('signed', true), + $attribute->getAttribute('array', false) ); - if ($attribute->getAttribute('array')) { - $attrType = 'LONGTEXT'; - } - $attributeStrings[$key] = "`{$attrId}` {$attrType}, "; } @@ -344,11 +341,15 @@ public function deleteAttribute(string $collection, string $id, bool $array = fa */ public function renameIndex(string $collection, string $old, string $new): bool { - $collection = $this->filter($collection); - $collectionDocument = $this->getDocument(Database::METADATA, $collection); + $collection = $this->getDocument(Database::METADATA, $collection); + + if ($collection->isEmpty()) { + throw new DatabaseException('Collection not found'); + } + $old = $this->filter($old); $new = $this->filter($new); - $indexes = json_decode($collectionDocument['indexes'], true); + $indexes = \json_decode($collection->getAttribute('indexes', []), true); $index = null; foreach ($indexes as $node) { @@ -359,9 +360,9 @@ public function renameIndex(string $collection, string $old, string $new): bool } if ($index - && $this->deleteIndex($collection, $old) + && $this->deleteIndex($collection->getId(), $old) && $this->createIndex( - $collection, + $collection->getId(), $new, $index['type'], $index['attributes'], @@ -392,13 +393,11 @@ public function createIndex(string $collection, string $id, string $type, array $name = $this->filter($collection); $id = $this->filter($id); - // Workaround for no support for CREATE INDEX IF NOT EXISTS $stmt = $this->getPDO()->prepare(" SELECT name FROM sqlite_master - WHERE type='index' - AND name=:_index; + WHERE type='index' AND name=:_index; "); $stmt->bindValue(':_index', "{$this->getNamespace()}_{$this->tenant}_{$name}_{$id}"); $stmt->execute(); @@ -1049,6 +1048,11 @@ public function getSupportForSchemas(): bool return false; } + public function getSupportForQueryContains(): bool + { + return false; + } + /** * Is fulltext index supported? * @@ -1095,14 +1099,13 @@ protected function getSQLIndexType(string $type): string { switch ($type) { case Database::INDEX_KEY: - case Database::INDEX_ARRAY: return 'INDEX'; case Database::INDEX_UNIQUE: return 'UNIQUE INDEX'; default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT); + throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT); } } @@ -1122,7 +1125,6 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr switch ($type) { case Database::INDEX_KEY: - case Database::INDEX_ARRAY: $type = 'INDEX'; break; @@ -1133,7 +1135,7 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr break; default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT); + throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT); } $attributes = \array_map(fn ($attribute) => match ($attribute) { diff --git a/src/Database/Database.php b/src/Database/Database.php index ca7920ac1..818ebb1b5 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -32,7 +32,7 @@ class Database public const VAR_BOOLEAN = 'boolean'; public const VAR_DATETIME = 'datetime'; - // Relationships Types + // Relationship Types public const VAR_RELATIONSHIP = 'relationship'; // Index Types @@ -40,7 +40,7 @@ class Database public const INDEX_FULLTEXT = 'fulltext'; public const INDEX_UNIQUE = 'unique'; public const INDEX_SPATIAL = 'spatial'; - public const INDEX_ARRAY = 'array'; + public const ARRAY_INDEX_LENGTH = 255; // Relation Types public const RELATION_ONE_TO_ONE = 'oneToOne'; @@ -2364,7 +2364,25 @@ public function createIndex(string $collection, string $id, string $type, array break; default: - throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT); + throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_FULLTEXT); + } + + /** @var array $collectionAttributes */ + $collectionAttributes = $collection->getAttribute('attributes', []); + + foreach ($attributes as $i => $attr) { + foreach ($collectionAttributes as $collectionAttribute) { + if($collectionAttribute->getAttribute('key') === $attr) { + $isArray = $collectionAttribute->getAttribute('array', false); + if($isArray) { + if($this->adapter->getMaxIndexLength() > 0) { + $lengths[$i] = self::ARRAY_INDEX_LENGTH; + } + $orders[$i] = null; + } + break; + } + } } $index = new Document([ @@ -4559,6 +4577,7 @@ public function find(string $collection, array $queries = []): array $cursor = empty($cursor) ? [] : $this->encode($collection, $cursor)->getArrayCopy(); + /** @var array $queries */ $queries = \array_merge( $selects, self::convertQueries($collection, $filters) @@ -5165,6 +5184,12 @@ public static function convertQueries(Document $collection, array $queries): arr $attributes = $collection->getAttribute('attributes', []); foreach ($attributes as $attribute) { + foreach ($queries as $query) { + if ($query->getAttribute() === $attribute->getId()) { + $query->setOnArray($attribute->getAttribute('array', false)); + } + } + if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { foreach ($queries as $index => $query) { if ($query->getAttribute() === $attribute->getId()) { diff --git a/src/Database/Query.php b/src/Database/Query.php index 471848e55..8c99dec10 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -70,6 +70,7 @@ class Query protected string $method = ''; protected string $attribute = ''; + protected bool $onArray = false; /** * @var array @@ -678,4 +679,21 @@ public function isNested(): bool return false; } + + /** + * @return bool + */ + public function onArray(): bool + { + return $this->onArray; + } + + /** + * @param bool $bool + * @return void + */ + public function setOnArray(bool $bool): void + { + $this->onArray = $bool; + } } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 6cdf9d89c..62a03b6f2 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -97,7 +97,6 @@ public function checkDuplicatedAttributes(Document $index): bool /** * @param Document $index * @return bool - * @throws DatabaseException */ public function checkFulltextIndexNonString(Document $index): bool { @@ -113,6 +112,51 @@ public function checkFulltextIndexNonString(Document $index): bool return true; } + /** + * @param Document $index + * @return bool + */ + public function checkArrayIndex(Document $index): bool + { + $attributes = $index->getAttribute('attributes', []); + $orders = $index->getAttribute('orders', []); + $lengths = $index->getAttribute('lengths', []); + + $arrayAttributes = []; + foreach ($attributes as $attributePosition => $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + + if($attribute->getAttribute('array', false)) { + // Database::INDEX_UNIQUE Is not allowed! since mariaDB VS MySQL makes the unique Different on values + if($index->getAttribute('type') != Database::INDEX_KEY) { + $this->message = '"' . ucfirst($index->getAttribute('type')) . '" index is forbidden on array attributes'; + return false; + } + + if(empty($lengths[$attributePosition])) { + $this->message = 'Index length for array not specified'; + return false; + } + + $arrayAttributes[] = $attribute->getAttribute('key', ''); + if(count($arrayAttributes) > 1) { + $this->message = 'An index may only contain one array attribute'; + return false; + } + + $direction = $orders[$attributePosition] ?? ''; + if(!empty($direction)) { + $this->message = 'Invalid index order "' . $direction . '" on array attribute "'. $attribute->getAttribute('key', '') .'"'; + return false; + } + } elseif($attribute->getAttribute('type') !== Database::VAR_STRING && !empty($lengths[$attributePosition])) { + $this->message = 'Cannot set a length on "'. $attribute->getAttribute('type') . '" attributes'; + return false; + } + } + return true; + } + /** * @param Document $index * @return bool @@ -144,6 +188,11 @@ public function checkIndexLength(Document $index): bool break; } + if($attribute->getAttribute('array', false)) { + $attributeSize = Database::ARRAY_INDEX_LENGTH; + $indexLength = Database::ARRAY_INDEX_LENGTH; + } + if ($indexLength > $attributeSize) { $this->message = 'Index length ' . $indexLength . ' is larger than the size for ' . $attributeName . ': ' . $attributeSize . '"'; return false; @@ -186,6 +235,10 @@ public function isValid($value): bool return false; } + if (!$this->checkArrayIndex($value)) { + return false; + } + if (!$this->checkIndexLength($value)) { return false; } diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 7b576136c..b3f287d4b 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -64,7 +64,7 @@ protected function isValidAttribute(string $attribute): bool * @param array $values * @return bool */ - protected function isValidAttributeAndValues(string $attribute, array $values): bool + protected function isValidAttributeAndValues(string $attribute, array $values, string $method): bool { if (!$this->isValidAttribute($attribute)) { return false; @@ -101,6 +101,25 @@ protected function isValidAttributeAndValues(string $attribute, array $values): } } + $array = $attributeSchema['array'] ?? false; + + if( + !$array && + $method === Query::TYPE_CONTAINS && + $attributeSchema['type'] !== Database::VAR_STRING + ) { + $this->message = 'Cannot query contains on attribute "' . $attribute . '" because it is not an array or string.'; + return false; + } + + if( + $array && + !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) + ) { + $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; + return false; + } + return true; } @@ -143,7 +162,7 @@ public function isValid($value): bool return false; } - return $this->isValidAttributeAndValues($attribute, $value->getValues()); + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_NOT_EQUAL: case Query::TYPE_LESSER: @@ -158,7 +177,7 @@ public function isValid($value): bool return false; } - return $this->isValidAttributeAndValues($attribute, $value->getValues()); + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_BETWEEN: if (count($value->getValues()) != 2) { @@ -166,11 +185,11 @@ public function isValid($value): bool return false; } - return $this->isValidAttributeAndValues($attribute, $value->getValues()); + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: - return $this->isValidAttributeAndValues($attribute, $value->getValues()); + return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_OR: case Query::TYPE_AND: diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 6eb5f4f37..7d47a0f40 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -1591,23 +1591,259 @@ public function testDeleteDocument(Document $document): void } + /** + * @throws AuthorizationException + * @throws DuplicateException + * @throws ConflictException + * @throws LimitException + * @throws StructureException + */ + public function testArrayAttribute(): void + { + Authorization::setRole(Role::any()->toString()); + + $database = static::getDatabase(); + $collection = 'json'; + $permissions = [Permission::read(Role::any())]; + + $database->createCollection($collection, permissions: [ + Permission::create(Role::any()), + ]); + + $this->assertEquals(true, $database->createAttribute( + $collection, + 'booleans', + Database::VAR_BOOLEAN, + size: 0, + required: true, + array: true + )); + + $this->assertEquals(true, $database->createAttribute( + $collection, + 'names', + Database::VAR_STRING, + size: 255, // Does this mean each Element max is 255? We need to check this on Structure validation? + required: false, + array: true + )); + + $this->assertEquals(true, $database->createAttribute( + $collection, + 'numbers', + Database::VAR_INTEGER, + size: 0, + required: false, + signed: false, + array: true + )); + + $this->assertEquals(true, $database->createAttribute( + $collection, + 'age', + Database::VAR_INTEGER, + size: 0, + required: false, + signed: false + )); + + $this->assertEquals(true, $database->createAttribute( + $collection, + 'tv_show', + Database::VAR_STRING, + size: 700, + required: false, + signed: false, + )); + + $this->assertEquals(true, $database->createAttribute( + $collection, + 'short', + Database::VAR_STRING, + size: 5, + required: false, + signed: false, + array: true + )); + + try { + $database->createDocument($collection, new Document([])); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Invalid document structure: Missing required attribute "booleans"', $e->getMessage()); + } + + $database->updateAttribute($collection, 'booleans', required: false); + + $doc = $database->getCollection($collection); + $attribute = $doc->getAttribute('attributes')[0]; + $this->assertEquals('boolean', $attribute['type']); + $this->assertEquals(true, $attribute['signed']); + $this->assertEquals(0, $attribute['size']); + $this->assertEquals(null, $attribute['default']); + $this->assertEquals(true, $attribute['array']); + $this->assertEquals(false, $attribute['required']); + + try { + $database->createDocument($collection, new Document([ + 'short' => ['More than 5 size'], + ])); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Invalid document structure: Attribute "short[\'0\']" has invalid type. Value must be a valid string and no longer than 5 chars', $e->getMessage()); + } + + try { + $database->createDocument($collection, new Document([ + 'names' => ['Joe', 100], + ])); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Invalid document structure: Attribute "names[\'1\']" has invalid type. Value must be a valid string and no longer than 255 chars', $e->getMessage()); + } + + try { + $database->createDocument($collection, new Document([ + 'age' => 1.5, + ])); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Invalid document structure: Attribute "age" has invalid type. Value must be a valid integer', $e->getMessage()); + } + + $database->createDocument($collection, new Document([ + '$id' => 'id1', + '$permissions' => $permissions, + 'booleans' => [false], + 'names' => ['Joe', 'Antony', '100'], + 'numbers' => [0, 100, 1000, -1], + 'age' => 41, + 'tv_show' => 'Everybody Loves Raymond', + ])); + + $document = $database->getDocument($collection, 'id1'); + + $this->assertEquals(false, $document->getAttribute('booleans')[0]); + $this->assertEquals('Antony', $document->getAttribute('names')[1]); + $this->assertEquals(100, $document->getAttribute('numbers')[1]); + + try { + $database->createIndex($collection, 'indx', Database::INDEX_FULLTEXT, ['names']); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + if ($this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { + $this->assertEquals('"Fulltext" index is forbidden on array attributes', $e->getMessage()); + } else { + $this->assertEquals('Fulltext index is not supported', $e->getMessage()); + } + } + + try { + $database->createIndex($collection, 'indx', Database::INDEX_KEY, ['numbers', 'names'], [100,100]); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('An index may only contain one array attribute', $e->getMessage()); + } + + $this->assertEquals(true, $database->createAttribute( + $collection, + 'long_size', + Database::VAR_STRING, + size: 2000, + required: false, + array: true + )); + + if ($database->getAdapter()->getMaxIndexLength() > 0) { + // If getMaxIndexLength() > 0 We clear length for array attributes + $database->createIndex($collection, 'indx1', Database::INDEX_KEY, ['long_size'], [], []); + $database->createIndex($collection, 'indx2', Database::INDEX_KEY, ['long_size'], [1000], []); + + try { + $database->createIndex($collection, 'indx_numbers', Database::INDEX_KEY, ['tv_show', 'numbers'], [], []); // [700, 255] + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Index length is longer than the maximum: 768', $e->getMessage()); + } + } + + // We clear orders for array attributes + $database->createIndex($collection, 'indx3', Database::INDEX_KEY, ['names'], [255], ['desc']); + + try { + $database->createIndex($collection, 'indx4', Database::INDEX_KEY, ['age', 'names'], [10, 255], []); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Cannot set a length on "integer" attributes', $e->getMessage()); + } + + $this->assertTrue($database->createIndex($collection, 'indx6', Database::INDEX_KEY, ['age', 'names'], [null, 999], [])); + $this->assertTrue($database->createIndex($collection, 'indx7', Database::INDEX_KEY, ['age', 'booleans'], [0, 999], [])); + + if ($this->getDatabase()->getAdapter()->getSupportForQueryContains()) { + try { + $database->find($collection, [ + Query::equal('names', ['Joe']), + ]); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Invalid query: Cannot query equal on attribute "names" because it is an array.', $e->getMessage()); + } + + try { + $database->find($collection, [ + Query::contains('age', [10]) + ]); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Invalid query: Cannot query contains on attribute "age" because it is not an array or string.', $e->getMessage()); + } + + $documents = $database->find($collection, [ + Query::isNull('long_size') + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::contains('tv_show', ['love']) + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::contains('names', ['Jake', 'Joe']) + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::contains('numbers', [-1, 0, 999]) + ]); + $this->assertCount(1, $documents); + + $documents = $database->find($collection, [ + Query::contains('booleans', [false, true]) + ]); + $this->assertCount(1, $documents); + } + } + /** * @return array */ public function testFind(): array { Authorization::setRole(Role::any()->toString()); + static::getDatabase()->createCollection('movies', permissions: [ Permission::create(Role::any()), Permission::update(Role::users()) - ], documentSecurity: true); + ]); $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'name', Database::VAR_STRING, 128, true)); $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'director', Database::VAR_STRING, 128, true)); $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'year', Database::VAR_INTEGER, 0, true)); $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'price', Database::VAR_FLOAT, 0, true)); $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'active', Database::VAR_BOOLEAN, 0, true)); - $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'generes', Database::VAR_STRING, 32, true, null, true, true)); + $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'genres', Database::VAR_STRING, 32, true, null, true, true)); $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'with-dash', Database::VAR_STRING, 128, true)); $this->assertEquals(true, static::getDatabase()->createAttribute('movies', 'nullable', Database::VAR_STRING, 128, false)); @@ -1632,7 +1868,7 @@ public function testFind(): array 'year' => 2013, 'price' => 39.50, 'active' => true, - 'generes' => ['animation', 'kids'], + 'genres' => ['animation', 'kids'], 'with-dash' => 'Works' ])); @@ -1656,7 +1892,7 @@ public function testFind(): array 'year' => 2019, 'price' => 39.50, 'active' => true, - 'generes' => ['animation', 'kids'], + 'genres' => ['animation', 'kids'], 'with-dash' => 'Works' ])); @@ -1680,7 +1916,7 @@ public function testFind(): array 'year' => 2011, 'price' => 25.94, 'active' => true, - 'generes' => ['science fiction', 'action', 'comics'], + 'genres' => ['science fiction', 'action', 'comics'], 'with-dash' => 'Works2' ])); @@ -1704,7 +1940,7 @@ public function testFind(): array 'year' => 2019, 'price' => 25.99, 'active' => true, - 'generes' => ['science fiction', 'action', 'comics'], + 'genres' => ['science fiction', 'action', 'comics'], 'with-dash' => 'Works2' ])); @@ -1728,7 +1964,7 @@ public function testFind(): array 'year' => 2025, 'price' => 0.0, 'active' => false, - 'generes' => [], + 'genres' => [], 'with-dash' => 'Works3' ])); @@ -1750,7 +1986,7 @@ public function testFind(): array 'year' => 2026, 'price' => 0.0, 'active' => false, - 'generes' => [], + 'genres' => [], 'with-dash' => 'Works3', 'nullable' => 'Not null' ])); @@ -1779,8 +2015,8 @@ public function testFindBasicChecks(): void $this->assertIsFloat($documents[0]->getAttribute('price')); $this->assertEquals(true, $documents[0]->getAttribute('active')); $this->assertIsBool($documents[0]->getAttribute('active')); - $this->assertEquals(['animation', 'kids'], $documents[0]->getAttribute('generes')); - $this->assertIsArray($documents[0]->getAttribute('generes')); + $this->assertEquals(['animation', 'kids'], $documents[0]->getAttribute('genres')); + $this->assertIsArray($documents[0]->getAttribute('genres')); $this->assertEquals('Works', $documents[0]->getAttribute('with-dash')); // Alphabetical order @@ -1950,7 +2186,7 @@ public function testFindContains(): void } $documents = static::getDatabase()->find('movies', [ - Query::contains('generes', ['comics']) + Query::contains('genres', ['comics']) ]); $this->assertEquals(2, count($documents)); @@ -1959,10 +2195,26 @@ public function testFindContains(): void * Array contains OR condition */ $documents = static::getDatabase()->find('movies', [ - Query::contains('generes', ['comics', 'kids']), + Query::contains('genres', ['comics', 'kids']), ]); $this->assertEquals(4, count($documents)); + + $documents = static::getDatabase()->find('movies', [ + Query::contains('genres', ['non-existent']), + ]); + + $this->assertEquals(0, count($documents)); + + try { + static::getDatabase()->find('movies', [ + Query::contains('price', [10.5]), + ]); + $this->fail('Failed to throw exception'); + } catch(Throwable $e) { + $this->assertEquals('Invalid query: Cannot query contains on attribute "price" because it is not an array or string.', $e->getMessage()); + $this->assertTrue($e instanceof DatabaseException); + } } public function testFindFulltext(): void @@ -4003,7 +4255,7 @@ public function testUniqueIndexDuplicate(): void 'year' => 2013, 'price' => 39.50, 'active' => true, - 'generes' => ['animation', 'kids'], + 'genres' => ['animation', 'kids'], 'with-dash' => 'Works4' ])); } @@ -4035,7 +4287,7 @@ public function testUniqueIndexDuplicateUpdate(): void 'year' => 2013, 'price' => 39.50, 'active' => true, - 'generes' => ['animation', 'kids'], + 'genres' => ['animation', 'kids'], 'with-dash' => 'Works4' ])); diff --git a/tests/unit/Validator/Query/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index 968d9a85d..01d38b98a 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -13,27 +13,52 @@ class FilterTest extends TestCase { protected Base|null $validator = null; + /** + * @throws \Utopia\Database\Exception + */ public function setUp(): void { $this->validator = new Filter( attributes: [ new Document([ - '$id' => 'attr', - 'key' => 'attr', + '$id' => 'string', + 'key' => 'string', 'type' => Database::VAR_STRING, 'array' => false, ]), + new Document([ + '$id' => 'string_array', + 'key' => 'string_array', + 'type' => Database::VAR_STRING, + 'array' => true, + ]), + new Document([ + '$id' => 'integer_array', + 'key' => 'integer_array', + 'type' => Database::VAR_INTEGER, + 'array' => true, + ]), + new Document([ + '$id' => 'integer', + 'key' => 'integer', + 'type' => Database::VAR_INTEGER, + 'array' => false, + ]), ], ); } public function testSuccess(): void { - $this->assertTrue($this->validator->isValid(Query::between('attr', '1975-12-06', '2050-12-06'))); - $this->assertTrue($this->validator->isValid(Query::isNotNull('attr'))); - $this->assertTrue($this->validator->isValid(Query::isNull('attr'))); - $this->assertTrue($this->validator->isValid(Query::startsWith('attr', 'super'))); - $this->assertTrue($this->validator->isValid(Query::endsWith('attr', 'man'))); + $this->assertTrue($this->validator->isValid(Query::between('string', '1975-12-06', '2050-12-06'))); + $this->assertTrue($this->validator->isValid(Query::isNotNull('string'))); + $this->assertTrue($this->validator->isValid(Query::isNull('string'))); + $this->assertTrue($this->validator->isValid(Query::startsWith('string', 'super'))); + $this->assertTrue($this->validator->isValid(Query::endsWith('string', 'man'))); + $this->assertTrue($this->validator->isValid(Query::contains('string_array', ['super']))); + $this->assertTrue($this->validator->isValid(Query::contains('integer_array', [100,10,-1]))); + $this->assertTrue($this->validator->isValid(Query::contains('string_array', ["1","10","-1"]))); + $this->assertTrue($this->validator->isValid(Query::contains('string', ['super']))); } public function testFailure(): void @@ -52,18 +77,21 @@ public function testFailure(): void $this->assertFalse($this->validator->isValid(Query::offset(5001))); $this->assertFalse($this->validator->isValid(Query::equal('dne', ['v']))); $this->assertFalse($this->validator->isValid(Query::equal('', ['v']))); - $this->assertFalse($this->validator->isValid(Query::orderAsc('attr'))); - $this->assertFalse($this->validator->isValid(Query::orderDesc('attr'))); + $this->assertFalse($this->validator->isValid(Query::orderAsc('string'))); + $this->assertFalse($this->validator->isValid(Query::orderDesc('string'))); $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_CURSOR_AFTER, values: ['asdf']))); $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_CURSOR_BEFORE, values: ['asdf']))); + $this->assertFalse($this->validator->isValid(Query::contains('integer', ['super']))); + $this->assertFalse($this->validator->isValid(Query::equal('integer_array', [100,-1]))); + $this->assertFalse($this->validator->isValid(Query::contains('integer_array', [10.6]))); } public function testEmptyValues(): void { - $this->assertFalse($this->validator->isValid(Query::contains('attr', []))); + $this->assertFalse($this->validator->isValid(Query::contains('string', []))); $this->assertEquals('Contains queries require at least one value.', $this->validator->getDescription()); - $this->assertFalse($this->validator->isValid(Query::equal('attr', []))); + $this->assertFalse($this->validator->isValid(Query::equal('string', []))); $this->assertEquals('Equal queries require at least one value.', $this->validator->getDescription()); } }