diff --git a/bin/cli.php b/bin/cli.php index 65a0638c0..f0a3ef411 100644 --- a/bin/cli.php +++ b/bin/cli.php @@ -13,6 +13,7 @@ include 'tasks/index.php'; include 'tasks/query.php'; include 'tasks/relationships.php'; +include 'tasks/operators.php'; $cli ->error() diff --git a/bin/operators b/bin/operators new file mode 100755 index 000000000..00f3639b1 --- /dev/null +++ b/bin/operators @@ -0,0 +1,3 @@ +#!/bin/sh + +php /usr/src/code/bin/cli.php operators "$@" diff --git a/bin/tasks/operators.php b/bin/tasks/operators.php new file mode 100644 index 000000000..632489128 --- /dev/null +++ b/bin/tasks/operators.php @@ -0,0 +1,938 @@ +task('operators') + ->desc('Benchmark operator performance vs traditional read-modify-write') + ->param('adapter', '', new Text(0), 'Database adapter (mariadb, postgres, sqlite)') + ->param('iterations', 1000, new Integer(true), 'Number of iterations per test', true) + ->param('name', 'operator_benchmark_' . uniqid(), new Text(0), 'Name of test database', true) + ->action(function (string $adapter, int $iterations, string $name) { + $namespace = '_ns'; + $cache = new Cache(new NoCache()); + + Console::info("============================================================="); + Console::info(" OPERATOR PERFORMANCE BENCHMARK"); + Console::info("============================================================="); + Console::info("Adapter: {$adapter}"); + Console::info("Iterations: {$iterations}"); + Console::info("Database: {$name}"); + Console::info("=============================================================\n"); + + // ------------------------------------------------------------------ + // 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, + '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' => 'root', + 'pass' => 'password', + 'dsn' => static fn (string $host, int $port) => "pgsql:host={$host};port={$port}", + 'adapter' => Postgres::class, + 'attrs' => Postgres::getPDOAttributes(), + ], + 'sqlite' => [ + 'host' => ':memory:', + 'port' => 0, + 'user' => '', + 'pass' => '', + 'dsn' => static fn (string $host, int $port) => "sqlite::memory:", + 'adapter' => SQLite::class, + 'attrs' => [], + ], + ]; + + if (!isset($dbAdapters[$adapter])) { + Console::error("Adapter '{$adapter}' not supported. Available: mariadb, postgres, sqlite"); + return; + } + + $cfg = $dbAdapters[$adapter]; + $dsn = ($cfg['dsn'])($cfg['host'], $cfg['port']); + + try { + // Initialize database connection + $pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], $cfg['attrs']); + + $database = (new Database(new ($cfg['adapter'])($pdo), $cache)) + ->setDatabase($name) + ->setNamespace($namespace); + + // Setup test environment + setupTestEnvironment($database, $name); + + // Run all benchmarks + $results = runAllBenchmarks($database, $iterations); + + // Display results + displayResults($results, $adapter, $iterations); + + // Cleanup + cleanup($database, $name); + + Console::success("\nBenchmark completed successfully!"); + + } catch (\Throwable $e) { + Console::error("Error: " . $e->getMessage()); + Console::error("Trace: " . $e->getTraceAsString()); + return; + } + }); + +/** + * Setup test environment with collections and sample data + */ +function setupTestEnvironment(Database $database, string $name): void +{ + Console::info("Setting up test environment..."); + + // Delete database if it exists + if ($database->exists($name)) { + $database->delete($name); + } + $database->create(); + + Authorization::setRole(Role::any()->toString()); + + // Create test collection + $database->createCollection('operators_test', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + // Create attributes for all operator types + // Numeric attributes + $database->createAttribute('operators_test', 'counter', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute('operators_test', 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute('operators_test', 'multiplier', Database::VAR_FLOAT, 0, false, 1.0); + $database->createAttribute('operators_test', 'divider', Database::VAR_FLOAT, 0, false, 100.0); + $database->createAttribute('operators_test', 'modulo_val', Database::VAR_INTEGER, 0, false, 100); + $database->createAttribute('operators_test', 'power_val', Database::VAR_FLOAT, 0, false, 2.0); + + // String attributes + $database->createAttribute('operators_test', 'name', Database::VAR_STRING, 200, false, 'test'); + $database->createAttribute('operators_test', 'text', Database::VAR_STRING, 500, false, 'initial'); + $database->createAttribute('operators_test', 'description', Database::VAR_STRING, 500, false, 'foo bar baz'); + + // Boolean attributes + $database->createAttribute('operators_test', 'active', Database::VAR_BOOLEAN, 0, false, true); + + // Array attributes + $database->createAttribute('operators_test', 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute('operators_test', 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createAttribute('operators_test', 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Date attributes + $database->createAttribute('operators_test', 'created_at', Database::VAR_DATETIME, 0, false, null, false, false, null, [], ['datetime']); + $database->createAttribute('operators_test', 'updated_at', Database::VAR_DATETIME, 0, false, null, false, false, null, [], ['datetime']); + + Console::success("Test environment setup complete.\n"); +} + +/** + * Run all operator benchmarks + */ +function runAllBenchmarks(Database $database, int $iterations): array +{ + $results = []; + $failed = []; + + Console::info("Starting benchmarks...\n"); + + // Helper function to safely run benchmarks + $safeBenchmark = function (string $name, callable $benchmark) use (&$results, &$failed) { + try { + $results[$name] = $benchmark(); + } catch (\Throwable $e) { + $failed[$name] = $e->getMessage(); + Console::warning(" ⚠️ {$name} failed: " . $e->getMessage()); + } + }; + + Console::info("=== Operation Type Benchmarks ===\n"); + + $safeBenchmark('UPDATE_SINGLE_NO_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'updateDocument', + false, + false + )); + + $safeBenchmark('UPDATE_SINGLE_WITH_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'updateDocument', + false, + true + )); + + $safeBenchmark('UPDATE_BULK_NO_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'updateDocuments', + false, + false + )); + + $safeBenchmark('UPDATE_BULK_WITH_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'updateDocuments', + false, + true + )); + + $safeBenchmark('UPSERT_SINGLE_NO_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'upsertDocument', + false, + false + )); + + $safeBenchmark('UPSERT_SINGLE_WITH_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'upsertDocument', + false, + true + )); + + $safeBenchmark('UPSERT_BULK_NO_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'upsertDocuments', + false, + false + )); + + $safeBenchmark('UPSERT_BULK_WITH_OPS', fn () => benchmarkOperation( + $database, + $iterations, + 'upsertDocuments', + false, + true + )); + + Console::info("\n=== Operator Type Benchmarks ===\n"); + + // Numeric operators + $safeBenchmark('INCREMENT', fn () => benchmarkOperator( + $database, + $iterations, + 'INCREMENT', + 'counter', + Operator::increment(1), + function ($doc) { + $doc->setAttribute('counter', $doc->getAttribute('counter', 0) + 1); + return $doc; + }, + ['counter' => 0] + )); + + $safeBenchmark('DECREMENT', fn () => benchmarkOperator( + $database, + $iterations, + 'DECREMENT', + 'counter', + Operator::decrement(1), + function ($doc) { + $doc->setAttribute('counter', $doc->getAttribute('counter', 100) - 1); + return $doc; + }, + ['counter' => 100] + )); + + $safeBenchmark('MULTIPLY', fn () => benchmarkOperator( + $database, + $iterations, + 'MULTIPLY', + 'multiplier', + Operator::multiply(1.1), + function ($doc) { + $doc->setAttribute('multiplier', $doc->getAttribute('multiplier', 1.0) * 1.1); + return $doc; + }, + ['multiplier' => 1.0] + )); + + $safeBenchmark('DIVIDE', fn () => benchmarkOperator( + $database, + $iterations, + 'DIVIDE', + 'divider', + Operator::divide(1.1), + function ($doc) { + $doc->setAttribute('divider', $doc->getAttribute('divider', 100.0) / 1.1); + return $doc; + }, + ['divider' => 100.0] + )); + + $safeBenchmark('MODULO', fn () => benchmarkOperator( + $database, + $iterations, + 'MODULO', + 'modulo_val', + Operator::modulo(7), + function ($doc) { + $val = $doc->getAttribute('modulo_val', 100); + $doc->setAttribute('modulo_val', $val % 7); + return $doc; + }, + ['modulo_val' => 100] + )); + + $safeBenchmark('POWER', fn () => benchmarkOperator( + $database, + $iterations, + 'POWER', + 'power_val', + Operator::power(1.001), + function ($doc) { + $doc->setAttribute('power_val', pow($doc->getAttribute('power_val', 2.0), 1.001)); + return $doc; + }, + ['power_val' => 2.0] + )); + + // String operators + $safeBenchmark('CONCAT', fn () => benchmarkOperator( + $database, + $iterations, + 'CONCAT', + 'text', + Operator::concat('x'), + function ($doc) { + $doc->setAttribute('text', $doc->getAttribute('text', 'initial') . 'x'); + return $doc; + }, + ['text' => 'initial'] + )); + + $safeBenchmark('REPLACE', fn () => benchmarkOperator( + $database, + $iterations, + 'REPLACE', + 'description', + Operator::replace('foo', 'bar'), + function ($doc) { + $doc->setAttribute('description', str_replace('foo', 'bar', $doc->getAttribute('description', 'foo bar baz'))); + return $doc; + }, + ['description' => 'foo bar baz'] + )); + + // Boolean operators + $safeBenchmark('TOGGLE', fn () => benchmarkOperator( + $database, + $iterations, + 'TOGGLE', + 'active', + Operator::toggle(), + function ($doc) { + $doc->setAttribute('active', !$doc->getAttribute('active', true)); + return $doc; + }, + ['active' => true] + )); + + // Array operators + $safeBenchmark('ARRAY_APPEND', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_APPEND', + 'tags', + Operator::arrayAppend(['new']), + function ($doc) { + $tags = $doc->getAttribute('tags', ['initial']); + $tags[] = 'new'; + $doc->setAttribute('tags', $tags); + return $doc; + }, + ['tags' => ['initial']] + )); + + $safeBenchmark('ARRAY_PREPEND', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_PREPEND', + 'tags', + Operator::arrayPrepend(['first']), + function ($doc) { + $tags = $doc->getAttribute('tags', ['initial']); + array_unshift($tags, 'first'); + $doc->setAttribute('tags', $tags); + return $doc; + }, + ['tags' => ['initial']] + )); + + $safeBenchmark('ARRAY_INSERT', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_INSERT', + 'numbers', + Operator::arrayInsert(1, 99), + function ($doc) { + $numbers = $doc->getAttribute('numbers', [1, 2, 3]); + array_splice($numbers, 1, 0, [99]); + $doc->setAttribute('numbers', $numbers); + return $doc; + }, + ['numbers' => [1, 2, 3]] + )); + + $safeBenchmark('ARRAY_REMOVE', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_REMOVE', + 'tags', + Operator::arrayRemove('unwanted'), + function ($doc) { + $tags = $doc->getAttribute('tags', ['keep', 'unwanted', 'also']); + $tags = array_values(array_filter($tags, fn ($t) => $t !== 'unwanted')); + $doc->setAttribute('tags', $tags); + return $doc; + }, + ['tags' => ['keep', 'unwanted', 'also']] + )); + + $safeBenchmark('ARRAY_UNIQUE', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_UNIQUE', + 'tags', + Operator::arrayUnique(), + function ($doc) { + $tags = $doc->getAttribute('tags', ['a', 'b', 'a', 'c', 'b']); + $doc->setAttribute('tags', array_values(array_unique($tags))); + return $doc; + }, + ['tags' => ['a', 'b', 'a', 'c', 'b']] + )); + + $safeBenchmark('ARRAY_INTERSECT', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_INTERSECT', + 'tags', + Operator::arrayIntersect(['keep', 'this']), + function ($doc) { + $tags = $doc->getAttribute('tags', ['keep', 'remove', 'this']); + $doc->setAttribute('tags', array_values(array_intersect($tags, ['keep', 'this']))); + return $doc; + }, + ['tags' => ['keep', 'remove', 'this']] + )); + + $safeBenchmark('ARRAY_DIFF', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_DIFF', + 'tags', + Operator::arrayDiff(['remove']), + function ($doc) { + $tags = $doc->getAttribute('tags', ['keep', 'remove', 'this']); + $doc->setAttribute('tags', array_values(array_diff($tags, ['remove']))); + return $doc; + }, + ['tags' => ['keep', 'remove', 'this']] + )); + + $safeBenchmark('ARRAY_FILTER', fn () => benchmarkOperator( + $database, + $iterations, + 'ARRAY_FILTER', + 'numbers', + Operator::arrayFilter('greaterThan', 5), + function ($doc) { + $numbers = $doc->getAttribute('numbers', [1, 3, 5, 7, 9]); + $doc->setAttribute('numbers', array_values(array_filter($numbers, fn ($n) => $n > 5))); + return $doc; + }, + ['numbers' => [1, 3, 5, 7, 9]] + )); + + // Date operators + $safeBenchmark('DATE_ADD_DAYS', fn () => benchmarkOperator( + $database, + $iterations, + 'DATE_ADD_DAYS', + 'created_at', + Operator::dateAddDays(1), + function ($doc) { + $date = new \DateTime($doc->getAttribute('created_at', DateTime::now())); + $date->modify('+1 day'); + $doc->setAttribute('created_at', DateTime::format($date)); + return $doc; + }, + ['created_at' => DateTime::now()] + )); + + $safeBenchmark('DATE_SUB_DAYS', fn () => benchmarkOperator( + $database, + $iterations, + 'DATE_SUB_DAYS', + 'updated_at', + Operator::dateSubDays(1), + function ($doc) { + $date = new \DateTime($doc->getAttribute('updated_at', DateTime::now())); + $date->modify('-1 day'); + $doc->setAttribute('updated_at', DateTime::format($date)); + return $doc; + }, + ['updated_at' => DateTime::now()] + )); + + $safeBenchmark('DATE_SET_NOW', fn () => benchmarkOperator( + $database, + $iterations, + 'DATE_SET_NOW', + 'updated_at', + Operator::dateSetNow(), + function ($doc) { + $doc->setAttribute('updated_at', DateTime::now()); + return $doc; + }, + ['updated_at' => DateTime::now()] + )); + + // Report any failures + if (!empty($failed)) { + Console::warning("\n⚠️ Some benchmarks failed:"); + foreach ($failed as $name => $error) { + Console::warning(" - {$name}: " . substr($error, 0, 100)); + } + } + + return $results; +} + +/** + * Benchmark operation type (update vs upsert, single vs bulk) with and without operators + */ +function benchmarkOperation( + Database $database, + int $iterations, + string $operation, + bool $isBulk, + bool $useOperators +): array { + $displayName = strtoupper($operation) . ($useOperators ? ' (with ops)' : ' (no ops)'); + Console::info("Benchmarking {$displayName}..."); + + $docId = 'bench_op_' . strtolower($operation) . '_' . ($useOperators ? 'ops' : 'noops'); + + // Create initial document + $baseData = [ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'counter' => 0, + 'name' => 'test', + 'score' => 100.0 + ]; + + $database->createDocument('operators_test', new Document(array_merge(['$id' => $docId], $baseData))); + + $memBefore = memory_get_usage(true); + $start = microtime(true); + + for ($i = 0; $i < $iterations; $i++) { + if ($operation === 'updateDocument') { + if ($useOperators) { + $database->updateDocument('operators_test', $docId, new Document([ + 'counter' => Operator::increment(1) + ])); + } else { + $database->updateDocument('operators_test', $docId, new Document([ + 'counter' => $i + 1 + ])); + } + } elseif ($operation === 'updateDocuments') { + if ($useOperators) { + $database->updateDocuments('operators_test', [ + new Document(['$id' => $docId, 'counter' => Operator::increment(1)]) + ]); + } else { + $database->updateDocuments('operators_test', [ + new Document(['$id' => $docId, 'counter' => $i + 1]) + ]); + } + } elseif ($operation === 'upsertDocument') { + if ($useOperators) { + $database->upsertDocument('operators_test', $docId, new Document([ + '$id' => $docId, + 'counter' => Operator::increment(1), + 'name' => 'test', + 'score' => 100.0 + ])); + } else { + $database->upsertDocument('operators_test', $docId, new Document([ + '$id' => $docId, + 'counter' => $i + 1, + 'name' => 'test', + 'score' => 100.0 + ])); + } + } elseif ($operation === 'upsertDocuments') { + if ($useOperators) { + $database->upsertDocuments('operators_test', '', [ + new Document(['$id' => $docId, 'counter' => Operator::increment(1), 'name' => 'test', 'score' => 100.0]) + ]); + } else { + $database->upsertDocuments('operators_test', '', [ + new Document(['$id' => $docId, 'counter' => $i + 1, 'name' => 'test', 'score' => 100.0]) + ]); + } + } + } + + $timeOp = microtime(true) - $start; + $memOp = memory_get_usage(true) - $memBefore; + + // Cleanup + $database->deleteDocument('operators_test', $docId); + + Console::success(" Time: {$timeOp}s | Memory: " . formatBytes($memOp)); + + return [ + 'operation' => $operation, + 'use_operators' => $useOperators, + 'time' => $timeOp, + 'memory' => $memOp, + 'iterations' => $iterations, + ]; +} + +/** + * Benchmark a single operator vs traditional approach + */ +function benchmarkOperator( + Database $database, + int $iterations, + string $operatorName, + string $attribute, + Operator $operator, + callable $traditionalModifier, + array $initialData +): array { + Console::info("Benchmarking {$operatorName}..."); + + // Prepare test documents + $docIdWith = 'bench_with_' . strtolower($operatorName); + $docIdWithout = 'bench_without_' . strtolower($operatorName); + + // Create fresh documents for this test + $baseData = array_merge([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ], $initialData); + + $database->createDocument('operators_test', new Document(array_merge(['$id' => $docIdWith], $baseData))); + $database->createDocument('operators_test', new Document(array_merge(['$id' => $docIdWithout], $baseData))); + + // Benchmark WITH operator + $memBefore = memory_get_usage(true); + $start = microtime(true); + + for ($i = 0; $i < $iterations; $i++) { + $database->updateDocument('operators_test', $docIdWith, new Document([ + $attribute => $operator + ])); + } + + $timeWith = microtime(true) - $start; + $memWith = memory_get_usage(true) - $memBefore; + + // Benchmark WITHOUT operator (traditional read-modify-write) + $memBefore = memory_get_usage(true); + $start = microtime(true); + + for ($i = 0; $i < $iterations; $i++) { + $doc = $database->getDocument('operators_test', $docIdWithout); + $doc = $traditionalModifier($doc); + $database->updateDocument('operators_test', $docIdWithout, $doc); + } + + $timeWithout = microtime(true) - $start; + $memWithout = memory_get_usage(true) - $memBefore; + + // Cleanup test documents + $database->deleteDocument('operators_test', $docIdWith); + $database->deleteDocument('operators_test', $docIdWithout); + + // Calculate metrics + $speedup = $timeWithout / $timeWith; + $improvement = (($timeWithout - $timeWith) / $timeWithout) * 100; + + // Color code the speedup output + if ($speedup > 1.0) { + Console::success(" WITH operator: {$timeWith}s | WITHOUT operator: {$timeWithout}s | Speedup: {$speedup}x"); + } elseif ($speedup >= 0.85) { + Console::warning(" WITH operator: {$timeWith}s | WITHOUT operator: {$timeWithout}s | Speedup: {$speedup}x"); + } else { + Console::error(" WITH operator: {$timeWith}s | WITHOUT operator: {$timeWithout}s | Speedup: {$speedup}x"); + } + + return [ + 'operator' => $operatorName, + 'attribute' => $attribute, + 'time_with' => $timeWith, + 'time_without' => $timeWithout, + 'memory_with' => $memWith, + 'memory_without' => $memWithout, + 'speedup' => $speedup, + 'improvement_percent' => $improvement, + 'iterations' => $iterations, + ]; +} + +/** + * Display formatted results table + */ +function displayResults(array $results, string $adapter, int $iterations): void +{ + Console::info("\n============================================================="); + Console::info(" BENCHMARK RESULTS"); + Console::info("============================================================="); + Console::info("Adapter: {$adapter}"); + Console::info("Iterations per test: {$iterations}"); + Console::info("=============================================================\n"); + + // ================================================================== + // OPERATION TYPE RESULTS + // ================================================================== + Console::info("=== OPERATION PERFORMANCE (Overhead Check) ===\n"); + Console::info("This section verifies NO OVERHEAD for non-operator operations:\n"); + + $opTypes = ['UPDATE_SINGLE', 'UPDATE_BULK', 'UPSERT_SINGLE', 'UPSERT_BULK']; + + foreach ($opTypes as $opType) { + $noOpsKey = $opType . '_NO_OPS'; + $withOpsKey = $opType . '_WITH_OPS'; + + if (isset($results[$noOpsKey]) && isset($results[$withOpsKey])) { + $noOps = $results[$noOpsKey]; + $withOps = $results[$withOpsKey]; + + $timeNoOps = number_format($noOps['time'], 4); + $timeWithOps = number_format($withOps['time'], 4); + + Console::info(str_pad($opType, 20) . ":"); + Console::info(" NO operators: {$timeNoOps}s"); + Console::info(" WITH operators: {$timeWithOps}s"); + Console::info(""); + } + } + + Console::info("=============================================================\n"); + + // Calculate column widths + $colWidths = [ + 'operator' => 20, + 'with' => 12, + 'without' => 12, + 'speedup' => 10, + 'improvement' => 14, + 'mem_diff' => 12, + ]; + + // Header + $header = sprintf( + "%-{$colWidths['operator']}s %-{$colWidths['with']}s %-{$colWidths['without']}s %-{$colWidths['speedup']}s %-{$colWidths['improvement']}s %-{$colWidths['mem_diff']}s", + 'OPERATOR', + 'WITH (s)', + 'WITHOUT (s)', + 'SPEEDUP', + 'IMPROVEMENT', + 'MEM DIFF' + ); + + Console::info($header); + Console::info(str_repeat('-', array_sum($colWidths) + 5)); + + // Group results by category + $categories = [ + 'Numeric' => ['INCREMENT', 'DECREMENT', 'MULTIPLY', 'DIVIDE', 'MODULO', 'POWER'], + 'String' => ['CONCAT', 'REPLACE'], + 'Boolean' => ['TOGGLE'], + 'Array' => ['ARRAY_APPEND', 'ARRAY_PREPEND', 'ARRAY_INSERT', 'ARRAY_REMOVE', 'ARRAY_UNIQUE', 'ARRAY_INTERSECT', 'ARRAY_DIFF', 'ARRAY_FILTER'], + 'Date' => ['DATE_ADD_DAYS', 'DATE_SUB_DAYS', 'DATE_SET_NOW'], + ]; + + $totalSpeedup = 0; + $totalCount = 0; + + foreach ($categories as $categoryName => $operators) { + Console::info("\n{$categoryName} Operators:"); + + foreach ($operators as $operatorName) { + if (!isset($results[$operatorName])) { + continue; + } + + $result = $results[$operatorName]; + + $timeWith = number_format($result['time_with'], 4); + $timeWithout = number_format($result['time_without'], 4); + $speedup = number_format($result['speedup'], 2); + $improvement = number_format($result['improvement_percent'], 1); + $memDiff = formatBytes($result['memory_without'] - $result['memory_with']); + + // Color code based on performance + // Red: <0.85x (regression), Yellow: 0.85-1.0x (slower), Green: >1.0x (faster) + $speedupDisplay = $result['speedup'] > 1.0 ? "\033[32m{$speedup}x\033[0m" : + ($result['speedup'] >= 0.85 ? "\033[33m{$speedup}x\033[0m" : + "\033[31m{$speedup}x\033[0m"); + + // Color improvement percent consistently with speedup + // Green: >0% (faster), Yellow: -15% to 0% (slower but acceptable), Red: <-15% (regression) + $improvementDisplay = $result['improvement_percent'] > 0 ? "\033[32m+{$improvement}%\033[0m" : + ($result['improvement_percent'] >= -15 ? "\033[33m{$improvement}%\033[0m" : + "\033[31m{$improvement}%\033[0m"); + + $row = sprintf( + " %-{$colWidths['operator']}s %-{$colWidths['with']}s %-{$colWidths['without']}s %-{$colWidths['speedup']}s %-{$colWidths['improvement']}s %-{$colWidths['mem_diff']}s", + $operatorName, + $timeWith, + $timeWithout, + $speedupDisplay, + $improvementDisplay, + $memDiff + ); + + Console::log($row); + + $totalSpeedup += $result['speedup']; + $totalCount++; + } + } + + // Summary statistics + $avgSpeedup = $totalCount > 0 ? $totalSpeedup / $totalCount : 0; + + Console::info("\n" . str_repeat('=', array_sum($colWidths) + 5)); + Console::info("SUMMARY:"); + Console::info(" Total operators tested: {$totalCount}"); + Console::info(" Average speedup: " . number_format($avgSpeedup, 2) . "x"); + + // Performance insights + Console::info("\n" . str_repeat('=', array_sum($colWidths) + 5)); + Console::info("PERFORMANCE INSIGHTS:"); + + $fastest = array_reduce( + $results, + fn ($carry, $item) => + $carry === null || $item['speedup'] > $carry['speedup'] ? $item : $carry + ); + + $slowest = array_reduce( + $results, + fn ($carry, $item) => + $carry === null || $item['speedup'] < $carry['speedup'] ? $item : $carry + ); + + if ($fastest) { + Console::success(" Fastest operator: {$fastest['operator']} (" . number_format($fastest['speedup'], 2) . "x speedup)"); + } + + if ($slowest) { + Console::warning(" Slowest operator: {$slowest['operator']} (" . number_format($slowest['speedup'], 2) . "x speedup)"); + } + + Console::info("\n=============================================================\n"); +} + +/** + * Format bytes to human-readable format + */ +function formatBytes(int $bytes): string +{ + if ($bytes === 0) { + return '0 B'; + } + + $sign = $bytes < 0 ? '-' : '+'; + $bytes = abs($bytes); + + $units = ['B', 'KB', 'MB', 'GB']; + $power = floor(log($bytes, 1024)); + $power = min($power, count($units) - 1); + + return $sign . round($bytes / pow(1024, $power), 2) . ' ' . $units[$power]; +} + +/** + * Cleanup test environment + */ +function cleanup(Database $database, string $name): void +{ + Console::info("Cleaning up test environment..."); + + try { + if ($database->exists($name)) { + $database->delete($name); + } + Console::success("Cleanup complete."); + } catch (\Throwable $e) { + Console::warning("Cleanup failed: " . $e->getMessage()); + } +} diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 9cd53a759..fd6bbd9a7 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1077,6 +1077,13 @@ abstract public function getSupportForSpatialAttributes(): bool; */ abstract public function getSupportForSpatialIndexNull(): bool; + /** + * Does the adapter support operators? + * + * @return bool + */ + abstract public function getSupportForOperators(): bool; + /** * Adapter supports optional spatial attributes with existing rows. * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index ea2f4781f..02391784a 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -8,11 +8,13 @@ use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Helpers\ID; +use Utopia\Database\Operator; use Utopia\Database\Query; class MariaDB extends SQL @@ -1111,16 +1113,32 @@ public function updateDocument(Document $collection, string $id, Document $docum * Update Attributes */ $bindIndex = 0; + $operators = []; + + // Separate regular attributes from operators + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; + } + } + foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); - $bindKey = 'key_' . $bindIndex; - if (in_array($attribute, $spatialAttributes)) { - $columns .= "`{$column}`" . '=' . $this->getSpatialGeomFromText(':' . $bindKey) . ','; + // Check if this is an operator or regular attribute + if (isset($operators[$attribute])) { + $operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $bindIndex); + $columns .= $operatorSQL . ','; } else { - $columns .= "`{$column}`" . '=:' . $bindKey . ','; + $bindKey = 'key_' . $bindIndex; + + if (in_array($attribute, $spatialAttributes)) { + $columns .= "`{$column}`" . '=' . $this->getSpatialGeomFromText(':' . $bindKey) . ','; + } else { + $columns .= "`{$column}`" . '=:' . $bindKey . ','; + } + $bindIndex++; } - $bindIndex++; } $sql = " @@ -1143,14 +1161,24 @@ public function updateDocument(Document $collection, string $id, Document $docum $attributeIndex = 0; foreach ($attributes as $attribute => $value) { - if (is_array($value)) { - $value = json_encode($value); - } + // Handle operators separately + if (isset($operators[$attribute])) { + $this->bindOperatorParams($stmt, $operators[$attribute], $attributeIndex); + } else { + // Convert spatial arrays to WKT, json_encode non-spatial arrays + if (\in_array($attribute, $spatialAttributes, true)) { + if (\is_array($value)) { + $value = $this->convertArrayToWKT($value); + } + } elseif (is_array($value)) { + $value = json_encode($value); + } - $bindKey = 'key_' . $attributeIndex; - $value = (is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; + $bindKey = 'key_' . $attributeIndex; + $value = (is_bool($value)) ? (int)$value : $value; + $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); + $attributeIndex++; + } } $stmt->execute(); @@ -1176,7 +1204,9 @@ public function updateDocument(Document $collection, string $id, Document $docum * @param array $attributes * @param array $bindValues * @param string $attribute + * @param array $operators * @return mixed + * @throws DatabaseException */ public function getUpsertStatement( string $tableName, @@ -1185,6 +1215,7 @@ public function getUpsertStatement( array $attributes, array $bindValues, string $attribute = '', + array $operators = [] ): mixed { $getUpdateClause = function (string $attribute, bool $increment = false): string { $attribute = $this->quote($this->filter($attribute)); @@ -1202,6 +1233,9 @@ public function getUpsertStatement( return "{$attribute} = {$new}"; }; + $updateColumns = []; + $bindIndex = count($bindValues); + if (!empty($attribute)) { // Increment specific column by its new value in place $updateColumns = [ @@ -1209,13 +1243,24 @@ public function getUpsertStatement( $getUpdateClause('_updatedAt'), ]; } else { - // Update all columns - $updateColumns = []; + // Update all columns, handling operators separately foreach (\array_keys($attributes) as $attr) { /** * @var string $attr */ - $updateColumns[] = $getUpdateClause($this->filter($attr)); + $filteredAttr = $this->filter($attr); + + // Check if this attribute has an operator + if (isset($operators[$attr])) { + $operatorSQL = $this->getOperatorSQL($filteredAttr, $operators[$attr], $bindIndex); + if ($operatorSQL !== null) { + $updateColumns[] = $operatorSQL; + } + } else { + if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) { + $updateColumns[] = $getUpdateClause($filteredAttr); + } + } } } @@ -1227,9 +1272,20 @@ public function getUpsertStatement( " . \implode(', ', $updateColumns) ); + // Bind regular attribute values foreach ($bindValues as $key => $binding) { $stmt->bindValue($key, $binding, $this->getPDOType($binding)); } + + $bindIndex = count($bindValues); + + // Bind operator parameters in the same order used to build SQL + foreach (array_keys($attributes) as $attr) { + if (isset($operators[$attr])) { + $this->bindOperatorParams($stmt, $operators[$attr], $bindIndex); + } + } + return $stmt; } @@ -1804,6 +1860,16 @@ protected function processException(PDOException $e): \Exception return new TruncateException('Resize would result in data truncation', $e->getCode(), $e); } + // Numeric value out of range + if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1264 || $e->errorInfo[1] === 1690)) { + return new LimitException('Value out of range', $e->getCode(), $e); + } + + // Numeric value out of range + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1690) { + return new LimitException('Value is out of range', $e->getCode(), $e); + } + // Unknown database if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) { return new NotFoundException('Database not found', $e->getCode(), $e); @@ -1834,6 +1900,175 @@ protected function quote(string $string): string return "`{$string}`"; } + /** + * Get operator SQL + * Override to handle MariaDB/MySQL-specific operators + * + * @param string $column + * @param Operator $operator + * @param int &$bindIndex + * @return ?string + */ + protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex): ?string + { + $quotedColumn = $this->quote($column); + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + // Numeric operators with NULL handling and overflow prevention + case Operator::TYPE_INCREMENT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey + WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN :$maxKey + ELSE COALESCE({$quotedColumn}, 0) + :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) + :$bindKey"; + + case Operator::TYPE_DECREMENT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) <= :$minKey THEN :$minKey + WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN :$minKey + ELSE COALESCE({$quotedColumn}, 0) - :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) - :$bindKey"; + + case Operator::TYPE_MULTIPLY: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey + WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN :$maxKey + WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN :$maxKey + ELSE COALESCE({$quotedColumn}, 0) * :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) * :$bindKey"; + + case Operator::TYPE_DIVIDE: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey <= :$minKey THEN :$minKey + ELSE COALESCE({$quotedColumn}, 0) / :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) / :$bindKey"; + + case Operator::TYPE_MODULO: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = MOD(COALESCE({$quotedColumn}, 0), :$bindKey)"; + + case Operator::TYPE_POWER: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey + WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) + WHEN :$bindKey * LOG(COALESCE({$quotedColumn}, 1)) > LOG(:$maxKey) THEN :$maxKey + ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) + END"; + } + return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; + + // Boolean operator with NULL handling + case Operator::TYPE_TOGGLE: + return "{$quotedColumn} = NOT COALESCE({$quotedColumn}, FALSE)"; + + case Operator::TYPE_CONCAT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CONCAT(COALESCE({$quotedColumn}, ''), :$bindKey)"; + + case Operator::TYPE_ARRAY_INSERT: + // Use JSON_ARRAY_INSERT for proper array insertion + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_ARRAY_INSERT({$quotedColumn}, CONCAT('$[', :$indexKey, ']'), JSON_EXTRACT(:$valueKey, '$'))"; + + case Operator::TYPE_ARRAY_INTERSECT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(jt1.value) + FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt1 + WHERE jt1.value IN (SELECT value FROM JSON_TABLE(:$bindKey, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt2) + ), JSON_ARRAY())"; + + case Operator::TYPE_ARRAY_DIFF: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(jt1.value) + FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt1 + WHERE jt1.value NOT IN (SELECT value FROM JSON_TABLE(:$bindKey, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt2) + ), JSON_ARRAY())"; + + case Operator::TYPE_ARRAY_UNIQUE: + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(DISTINCT jt.value) + FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt + ), JSON_ARRAY())"; + + case Operator::TYPE_ARRAY_REMOVE: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(value) + FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt + WHERE value != :$bindKey + ), JSON_ARRAY())"; + + case Operator::TYPE_ARRAY_FILTER: + $conditionKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + // Note: parent binds value as JSON-encoded, so we need to unquote it for TEXT comparison + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(value) + FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt + WHERE CASE :$conditionKey + WHEN 'equals' THEN value = JSON_UNQUOTE(:$valueKey) + WHEN 'notEquals' THEN value != JSON_UNQUOTE(:$valueKey) + WHEN 'greaterThan' THEN CAST(value AS DECIMAL(65,30)) > CAST(JSON_UNQUOTE(:$valueKey) AS DECIMAL(65,30)) + WHEN 'lessThan' THEN CAST(value AS DECIMAL(65,30)) < CAST(JSON_UNQUOTE(:$valueKey) AS DECIMAL(65,30)) + WHEN 'null' THEN value IS NULL + WHEN 'notNull' THEN value IS NOT NULL + ELSE TRUE + END + ), JSON_ARRAY())"; + + default: + // Fall back to parent implementation for other operators + return parent::getOperatorSQL($column, $operator, $bindIndex); + } + } + public function getSupportForNumericCasting(): bool { return true; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 82afb0eaa..887b9933c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2889,6 +2889,16 @@ public function getSupportForSpatialIndexNull(): bool return false; } + /** + * Does the adapter support operators? + * + * @return bool + */ + public function getSupportForOperators(): bool + { + return false; + } + /** * Does the adapter includes boundary during spatial contains? * diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 22c561ff9..2ff77e9a0 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -8,6 +8,7 @@ use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Operator; use Utopia\Database\Query; class MySQL extends MariaDB @@ -264,4 +265,43 @@ public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool { return false; } + + /** + * Get SQL expression for operator + * Override for MySQL-specific operator implementations + * + * @param string $column + * @param \Utopia\Database\Operator $operator + * @param int &$bindIndex + * @return ?string + */ + protected function getOperatorSQL(string $column, \Utopia\Database\Operator $operator, int &$bindIndex): ?string + { + $quotedColumn = $this->quote($column); + $method = $operator->getMethod(); + + switch ($method) { + case Operator::TYPE_ARRAY_APPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)"; + + case Operator::TYPE_ARRAY_PREPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; + + case Operator::TYPE_ARRAY_UNIQUE: + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(value) + FROM ( + SELECT DISTINCT value + FROM JSON_TABLE({$quotedColumn}, '\$[*]' COLUMNS(value TEXT PATH '\$')) AS jt + ) AS distinct_values + ), JSON_ARRAY())"; + } + + // For all other operators, use parent implementation + return parent::getOperatorSQL($column, $operator, $bindIndex); + } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 6eba933ec..adb9a409c 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -400,6 +400,11 @@ public function getSupportForAttributeResizing(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForOperators(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getSupportForGetConnectionId(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 1b7b67fbd..2151ac8f2 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -9,11 +9,13 @@ use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Truncate as TruncateException; use Utopia\Database\Helpers\ID; +use Utopia\Database\Operator; use Utopia\Database\Query; class Postgres extends SQL @@ -1090,6 +1092,7 @@ public function createDocument(Document $collection, Document $document): Docume */ public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { + $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1238,11 +1241,31 @@ public function updateDocument(Document $collection, string $id, Document $docum */ $bindIndex = 0; + $operators = []; + + // Separate regular attributes from operators + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; + } + } + foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); - $bindKey = 'key_' . $bindIndex; - $columns .= "\"{$column}\"" . '=:' . $bindKey . ','; - $bindIndex++; + + // Check if this is an operator, spatial attribute, or regular attribute + if (isset($operators[$attribute])) { + $operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $bindIndex); + $columns .= $operatorSQL . ','; + } elseif (\in_array($attribute, $spatialAttributes, true)) { + $bindKey = 'key_' . $bindIndex; + $columns .= "\"{$column}\" = " . $this->getSpatialGeomFromText(':' . $bindKey) . ','; + $bindIndex++; + } else { + $bindKey = 'key_' . $bindIndex; + $columns .= "\"{$column}\"" . '=:' . $bindKey . ','; + $bindIndex++; + } } $sql = " @@ -1265,14 +1288,24 @@ public function updateDocument(Document $collection, string $id, Document $docum $attributeIndex = 0; foreach ($attributes as $attribute => $value) { - if (is_array($value)) { - $value = json_encode($value); - } + // Handle operators separately + if (isset($operators[$attribute])) { + $this->bindOperatorParams($stmt, $operators[$attribute], $attributeIndex); + } else { + // Convert spatial arrays to WKT, json_encode non-spatial arrays + if (\in_array($attribute, $spatialAttributes, true)) { + if (\is_array($value)) { + $value = $this->convertArrayToWKT($value); + } + } elseif (is_array($value)) { + $value = json_encode($value); + } - $bindKey = 'key_' . $attributeIndex; - $value = (is_bool($value)) ? ($value == true ? "true" : "false") : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; + $bindKey = 'key_' . $attributeIndex; + $value = (is_bool($value)) ? ($value == true ? "true" : "false") : $value; + $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); + $attributeIndex++; + } } try { @@ -1297,6 +1330,7 @@ public function updateDocument(Document $collection, string $id, Document $docum * @param array $attributes * @param array $bindValues * @param string $attribute + * @param array $operators * @return mixed */ protected function getUpsertStatement( @@ -1306,6 +1340,7 @@ protected function getUpsertStatement( array $attributes, array $bindValues, string $attribute = '', + array $operators = [], ): mixed { $getUpdateClause = function (string $attribute, bool $increment = false): string { $attribute = $this->quote($this->filter($attribute)); @@ -1321,6 +1356,9 @@ protected function getUpsertStatement( return "{$attribute} = {$new}"; }; + + $bindIndex = count($bindValues); + if (!empty($attribute)) { // Increment specific column by its new value in place $updateColumns = [ @@ -1328,13 +1366,25 @@ protected function getUpsertStatement( $getUpdateClause('_updatedAt'), ]; } else { - // Update all columns + // Update all columns and apply operators $updateColumns = []; foreach (array_keys($attributes) as $attr) { /** * @var string $attr */ - $updateColumns[] = $getUpdateClause($this->filter($attr)); + $filteredAttr = $this->filter($attr); + + // Check if this attribute has an operator + if (isset($operators[$attr])) { + $operatorSQL = $this->getOperatorSQL($filteredAttr, $operators[$attr], $bindIndex, useTargetPrefix: true); + if ($operatorSQL !== null) { + $updateColumns[] = $operatorSQL; + } + } else { + if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) { + $updateColumns[] = $getUpdateClause($filteredAttr); + } + } } } @@ -1351,6 +1401,16 @@ protected function getUpsertStatement( foreach ($bindValues as $key => $binding) { $stmt->bindValue($key, $binding, $this->getPDOType($binding)); } + + $bindIndex = count($bindValues); + + // Bind operator parameters in the same order used to build SQL + foreach (array_keys($attributes) as $attr) { + if (isset($operators[$attr])) { + $this->bindOperatorParams($stmt, $operators[$attr], $bindIndex); + } + } + return $stmt; } @@ -1997,6 +2057,16 @@ protected function processException(PDOException $e): \Exception return new TruncateException('Resize would result in data truncation', $e->getCode(), $e); } + // Numeric value out of range (overflow/underflow from operators) + if ($e->getCode() === '22003' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Numeric value out of range', $e->getCode(), $e); + } + + // Datetime field overflow + if ($e->getCode() === '22008' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Datetime field overflow', $e->getCode(), $e); + } + // Unknown column if ($e->getCode() === "42703" && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return new NotFoundException('Attribute not found', $e->getCode(), $e); @@ -2321,4 +2391,263 @@ public function decodePolygon(string $wkb): array return $rings; // array of rings, each ring is array of [x,y] } + + /** + * Get SQL expression for operator + * + * @param string $column + * @param Operator $operator + * @param int &$bindIndex + * @return ?string + */ + protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex, bool $useTargetPrefix = false): ?string + { + $quotedColumn = $this->quote($column); + $columnRef = $useTargetPrefix ? "target.{$quotedColumn}" : $quotedColumn; + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + // Numeric operators with NULL handling + case Operator::TYPE_INCREMENT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$columnRef}, 0) >= CAST(:$maxKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) + WHEN COALESCE({$columnRef}, 0) > CAST(:$maxKey AS NUMERIC) - CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) + ELSE COALESCE({$columnRef}, 0) + CAST(:$bindKey AS NUMERIC) + END"; + } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) + :$bindKey"; + + case Operator::TYPE_DECREMENT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$columnRef}, 0) <= CAST(:$minKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC) + WHEN COALESCE({$columnRef}, 0) < CAST(:$minKey AS NUMERIC) + CAST(:$bindKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC) + ELSE COALESCE({$columnRef}, 0) - CAST(:$bindKey AS NUMERIC) + END"; + } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) - :$bindKey"; + + case Operator::TYPE_MULTIPLY: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$columnRef}, 0) >= CAST(:$maxKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) + WHEN CAST(:$bindKey AS NUMERIC) > 0 AND COALESCE({$columnRef}, 0) > CAST(:$maxKey AS NUMERIC) / CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) + WHEN CAST(:$bindKey AS NUMERIC) < 0 AND COALESCE({$columnRef}, 0) < CAST(:$maxKey AS NUMERIC) / CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC) + ELSE COALESCE({$columnRef}, 0) * CAST(:$bindKey AS NUMERIC) + END"; + } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) * :$bindKey"; + + case Operator::TYPE_DIVIDE: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN CAST(:$bindKey AS NUMERIC) != 0 AND COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC) <= CAST(:$minKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC) + ELSE COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC) + END"; + } + return "{$quotedColumn} = COALESCE({$columnRef}, 0) / :$bindKey"; + + case Operator::TYPE_MODULO: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // PostgreSQL MOD requires compatible types - cast to numeric + return "{$quotedColumn} = MOD(COALESCE({$columnRef}::numeric, 0), :$bindKey::numeric)"; + + case Operator::TYPE_POWER: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$columnRef}, 0) >= :$maxKey THEN :$maxKey + WHEN COALESCE({$columnRef}, 0) <= 1 THEN COALESCE({$columnRef}, 0) + WHEN :$bindKey * LN(COALESCE({$columnRef}, 1)) > LN(:$maxKey) THEN :$maxKey + ELSE POWER(COALESCE({$columnRef}, 0), :$bindKey) + END"; + } + return "{$quotedColumn} = POWER(COALESCE({$columnRef}, 0), :$bindKey)"; + + case Operator::TYPE_CONCAT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CONCAT(COALESCE({$columnRef}, ''), :$bindKey)"; + + case Operator::TYPE_ARRAY_APPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE({$columnRef}, '[]'::jsonb) || :$bindKey::jsonb"; + + case Operator::TYPE_ARRAY_PREPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = :$bindKey::jsonb || COALESCE({$columnRef}, '[]'::jsonb)"; + + case Operator::TYPE_ARRAY_UNIQUE: + // PostgreSQL-specific implementation for array unique + return "{$quotedColumn} = COALESCE(( + SELECT jsonb_agg(DISTINCT value) + FROM jsonb_array_elements({$columnRef}) AS value + ), '[]'::jsonb)"; + + case Operator::TYPE_ARRAY_REMOVE: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE(( + SELECT jsonb_agg(value) + FROM jsonb_array_elements({$columnRef}) AS value + WHERE value != :$bindKey::jsonb + ), '[]'::jsonb)"; + + case Operator::TYPE_ARRAY_INSERT: + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + // PostgreSQL implementation using jsonb functions with row numbering + return "{$quotedColumn} = ( + SELECT jsonb_agg(value ORDER BY idx) + FROM ( + SELECT value, idx + FROM jsonb_array_elements({$columnRef}) WITH ORDINALITY AS t(value, idx) + WHERE idx - 1 < :$indexKey + UNION ALL + SELECT :$valueKey::jsonb AS value, :$indexKey + 1 AS idx + UNION ALL + SELECT value, idx + 1 + FROM jsonb_array_elements({$columnRef}) WITH ORDINALITY AS t(value, idx) + WHERE idx - 1 >= :$indexKey + ) AS combined + )"; + + case Operator::TYPE_ARRAY_INTERSECT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE(( + SELECT jsonb_agg(value) + FROM jsonb_array_elements({$columnRef}) AS value + WHERE value IN (SELECT jsonb_array_elements(:$bindKey::jsonb)) + ), '[]'::jsonb)"; + + case Operator::TYPE_ARRAY_DIFF: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE(( + SELECT jsonb_agg(value) + FROM jsonb_array_elements({$columnRef}) AS value + WHERE value NOT IN (SELECT jsonb_array_elements(:$bindKey::jsonb)) + ), '[]'::jsonb)"; + + case Operator::TYPE_ARRAY_FILTER: + $conditionKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + // PostgreSQL-specific implementation using jsonb_array_elements + return "{$quotedColumn} = COALESCE(( + SELECT jsonb_agg(value) + FROM jsonb_array_elements({$columnRef}) AS value + WHERE CASE :$conditionKey + WHEN 'equals' THEN value = :$valueKey::jsonb + WHEN 'notEquals' THEN value != :$valueKey::jsonb + WHEN 'greaterThan' THEN (value::text)::numeric > trim(both '\"' from :$valueKey::text)::numeric + WHEN 'lessThan' THEN (value::text)::numeric < trim(both '\"' from :$valueKey::text)::numeric + WHEN 'null' THEN value = 'null'::jsonb + WHEN 'notNull' THEN value != 'null'::jsonb + ELSE TRUE + END + ), '[]'::jsonb)"; + + case Operator::TYPE_REPLACE: + $searchKey = "op_{$bindIndex}"; + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = REPLACE(COALESCE({$columnRef}, ''), :$searchKey, :$replaceKey)"; + + case Operator::TYPE_TOGGLE: + return "{$quotedColumn} = NOT COALESCE({$columnRef}, FALSE)"; + + case Operator::TYPE_DATE_ADD_DAYS: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = {$quotedColumn} + (:$bindKey || ' days')::INTERVAL"; + + case Operator::TYPE_DATE_SUB_DAYS: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = {$quotedColumn} - (:$bindKey || ' days')::INTERVAL"; + + case Operator::TYPE_DATE_SET_NOW: + return "{$quotedColumn} = NOW()"; + + default: + // Fall back to parent implementation for other operators + return parent::getOperatorSQL($column, $operator, $bindIndex); + } + } + + /** + * Bind operator parameters to statement + * Override to handle PostgreSQL-specific JSON binding + * + * @param \PDOStatement $stmt + * @param Operator $operator + * @param int &$bindIndex + * @return void + */ + protected function bindOperatorParams(\PDOStatement $stmt, Operator $operator, int &$bindIndex): void + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + case Operator::TYPE_ARRAY_APPEND: + case Operator::TYPE_ARRAY_PREPEND: + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $arrayValue, \PDO::PARAM_STR); + $bindIndex++; + break; + + case Operator::TYPE_ARRAY_REMOVE: + $value = $values[0] ?? null; + $bindKey = "op_{$bindIndex}"; + // Always JSON encode for PostgreSQL jsonb comparison + $stmt->bindValue(':' . $bindKey, json_encode($value), \PDO::PARAM_STR); + $bindIndex++; + break; + + case Operator::TYPE_ARRAY_INTERSECT: + case Operator::TYPE_ARRAY_DIFF: + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $arrayValue, \PDO::PARAM_STR); + $bindIndex++; + break; + + default: + // Use parent implementation for other operators + parent::bindOperatorParams($stmt, $operator, $bindIndex); + break; + } + } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index eac1b3ad1..ac4bb83b7 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -13,6 +13,7 @@ use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; +use Utopia\Database\Operator; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -20,6 +21,12 @@ abstract class SQL extends Adapter { protected mixed $pdo; + /** + * Maximum array size for array operations to prevent memory exhaustion. + * Large arrays in JSON_TABLE operations can cause significant memory usage. + */ + protected const MAX_ARRAY_OPERATOR_SIZE = 10000; + /** * Controls how many fractional digits are used when binding float parameters. */ @@ -478,20 +485,39 @@ public function updateDocuments(Document $collection, Document $updates, array $ $bindIndex = 0; $columns = ''; + $operators = []; + + // Separate regular attributes from operators + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; + } + } + foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); - if (in_array($attribute, $spatialAttributes)) { + // Check if this is an operator, spatial attribute, or regular attribute + if (isset($operators[$attribute])) { + $columns .= $this->getOperatorSQL($column, $operators[$attribute], $bindIndex); + } elseif (\in_array($attribute, $spatialAttributes)) { $columns .= "{$this->quote($column)} = " . $this->getSpatialGeomFromText(":key_{$bindIndex}"); + $bindIndex++; } else { $columns .= "{$this->quote($column)} = :key_{$bindIndex}"; + $bindIndex++; } if ($attribute !== \array_key_last($attributes)) { $columns .= ','; } + } - $bindIndex++; + // Remove trailing comma if present + $columns = \rtrim($columns, ','); + + if (empty($columns)) { + return 0; } $name = $this->filter($collection); @@ -517,17 +543,36 @@ public function updateDocuments(Document $collection, Document $updates, array $ $attributeIndex = 0; foreach ($attributes as $attributeName => $value) { - if (!isset($spatialAttributes[$attributeName]) && is_array($value)) { - $value = json_encode($value); + // Skip operators as they don't need value binding + if (isset($operators[$attributeName])) { + $this->bindOperatorParams($stmt, $operators[$attributeName], $attributeIndex); + continue; + } + + // Convert spatial arrays to WKT, json_encode non-spatial arrays + if (\in_array($attributeName, $spatialAttributes, true)) { + if (\is_array($value)) { + $value = $this->convertArrayToWKT($value); + } + } elseif (\is_array($value)) { + $value = \json_encode($value); } $bindKey = 'key_' . $attributeIndex; - $value = (is_bool($value)) ? (int)$value : $value; + // For PostgreSQL, preserve boolean values directly + if (!($this instanceof \Utopia\Database\Adapter\Postgres && \is_bool($value))) { + $value = (\is_bool($value)) ? (int)$value : $value; + } $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); $attributeIndex++; } - $stmt->execute(); + try { + $stmt->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } + $affected = $stmt->rowCount(); // Permissions logic @@ -1507,6 +1552,16 @@ public function getSupportForSpatialIndexNull(): bool return false; } + /** + * Does the adapter support operators? + * + * @return bool + */ + public function getSupportForOperators(): bool + { + return true; + } + /** * Does the adapter support order attribute in spatial indexes? * @@ -1635,6 +1690,7 @@ protected function getSpatialAxisOrderSpec(): string * @param array $bindValues * @param array $attributes * @param string $attribute + * @param array $operators * @return mixed */ abstract protected function getUpsertStatement( @@ -1644,6 +1700,7 @@ abstract protected function getUpsertStatement( array $attributes, array $bindValues, string $attribute = '', + array $operators = [] ): mixed; /** @@ -1798,6 +1855,454 @@ protected function getSQLTable(string $name): string return "{$this->quote($this->getDatabase())}.{$this->quote($this->getNamespace() . '_' .$this->filter($name))}"; } + /** + * Generate SQL expression for operator + * + * @param string $column + * @param \Utopia\Database\Operator $operator + * @param int &$bindIndex + * @return string|null Returns null if operator can't be expressed in SQL + */ + protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex): ?string + { + $quotedColumn = $this->quote($column); + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + case Operator::TYPE_INCREMENT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // Handle max limit if provided + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = LEAST(COALESCE({$quotedColumn}, 0) + :$bindKey, :$maxKey)"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) + :$bindKey"; + + case Operator::TYPE_DECREMENT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // Handle min limit if provided + if (isset($values[1])) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = GREATEST(COALESCE({$quotedColumn}, 0) - :$bindKey, :$minKey)"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) - :$bindKey"; + + case Operator::TYPE_MULTIPLY: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // Handle max limit if provided + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = LEAST(COALESCE({$quotedColumn}, 0) * :$bindKey, :$maxKey)"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) * :$bindKey"; + + case Operator::TYPE_DIVIDE: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // Handle min limit if provided + if (isset($values[1])) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = GREATEST(COALESCE({$quotedColumn}, 0) / :$bindKey, :$minKey)"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) / :$bindKey"; + + case Operator::TYPE_MODULO: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = MOD(COALESCE({$quotedColumn}, 0), :$bindKey)"; + + case Operator::TYPE_POWER: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // Handle max limit if provided + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = LEAST(POWER(COALESCE({$quotedColumn}, 0), :$bindKey), :$maxKey)"; + } + return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; + + // String operators + case Operator::TYPE_CONCAT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CONCAT(IFNULL({$quotedColumn}, ''), :$bindKey)"; + + case Operator::TYPE_REPLACE: + $searchKey = "op_{$bindIndex}"; + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = REPLACE({$quotedColumn}, :$searchKey, :$replaceKey)"; + + // Boolean operators with NULL handling + case Operator::TYPE_TOGGLE: + return "{$quotedColumn} = NOT COALESCE({$quotedColumn}, FALSE)"; + + // Date operators + case Operator::TYPE_DATE_ADD_DAYS: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = DATE_ADD({$quotedColumn}, INTERVAL :$bindKey DAY)"; + + case Operator::TYPE_DATE_SUB_DAYS: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = DATE_SUB({$quotedColumn}, INTERVAL :$bindKey DAY)"; + + case Operator::TYPE_DATE_SET_NOW: + return "{$quotedColumn} = NOW()"; + + // Array operators (using JSON functions) + case Operator::TYPE_ARRAY_APPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, '[]'), :$bindKey)"; + + case Operator::TYPE_ARRAY_PREPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, '[]'))"; + + case Operator::TYPE_ARRAY_REMOVE: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_REMOVE({$quotedColumn}, JSON_UNQUOTE(JSON_SEARCH({$quotedColumn}, 'one', :$bindKey)))"; + + case Operator::TYPE_ARRAY_UNIQUE: + return "{$quotedColumn} = IFNULL((SELECT JSON_ARRAYAGG(DISTINCT value) FROM JSON_TABLE({$quotedColumn}, '$[*]' COLUMNS(value JSON PATH '$')) AS jt), '[]')"; + + case Operator::TYPE_ARRAY_INSERT: + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = JSON_INSERT({$quotedColumn}, CONCAT('$[', :$indexKey, ']'), :$valueKey)"; + + case Operator::TYPE_ARRAY_INTERSECT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(jt1.value) + FROM JSON_TABLE({$quotedColumn}, '$[*]' COLUMNS(value JSON PATH '$')) AS jt1 + WHERE jt1.value IN ( + SELECT jt2.value + FROM JSON_TABLE(:$bindKey, '$[*]' COLUMNS(value JSON PATH '$')) AS jt2 + ) + ), '[]')"; + + case Operator::TYPE_ARRAY_DIFF: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL(( + SELECT JSON_ARRAYAGG(jt1.value) + FROM JSON_TABLE({$quotedColumn}, '$[*]' COLUMNS(value JSON PATH '$')) AS jt1 + WHERE jt1.value NOT IN ( + SELECT jt2.value + FROM JSON_TABLE(:$bindKey, '$[*]' COLUMNS(value JSON PATH '$')) AS jt2 + ) + ), '[]')"; + + case Operator::TYPE_ARRAY_FILTER: + $conditionKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = ( + SELECT JSON_ARRAYAGG(value) + FROM JSON_TABLE({$quotedColumn}, '$[*]' COLUMNS(value JSON PATH '$')) AS jt + WHERE CASE :$conditionKey + WHEN 'equal' THEN value = :$valueKey + WHEN 'notEqual' THEN value != :$valueKey + WHEN 'greaterThan' THEN CAST(value AS SIGNED) > CAST(:$valueKey AS SIGNED) + WHEN 'lessThan' THEN CAST(value AS SIGNED) < CAST(:$valueKey AS SIGNED) + WHEN 'isNull' THEN value IS NULL + WHEN 'isNotNull' THEN value IS NOT NULL + ELSE TRUE + END + )"; + default: + throw new DatabaseException("Unsupported operator type: {$method}"); + } + } + + /** + * Bind operator parameters to prepared statement + * + * @param \PDOStatement $stmt + * @param \Utopia\Database\Operator $operator + * @param int &$bindIndex + * @return void + */ + protected function bindOperatorParams(\PDOStatement $stmt, Operator $operator, int &$bindIndex): void + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + // Numeric operators with optional limits + case Operator::TYPE_INCREMENT: + case Operator::TYPE_DECREMENT: + case Operator::TYPE_MULTIPLY: + case Operator::TYPE_DIVIDE: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); + $bindIndex++; + + // Bind limit if provided + if (isset($values[1])) { + $limitKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $limitKey, $values[1], $this->getPDOType($values[1])); + $bindIndex++; + } + break; + + case Operator::TYPE_MODULO: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); + $bindIndex++; + break; + + case Operator::TYPE_POWER: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); + $bindIndex++; + + // Bind max limit if provided + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $maxKey, $values[1], $this->getPDOType($values[1])); + $bindIndex++; + } + break; + + // String operators + case Operator::TYPE_CONCAT: + $value = $values[0] ?? ''; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $value, \PDO::PARAM_STR); + $bindIndex++; + break; + + case Operator::TYPE_REPLACE: + $search = $values[0] ?? ''; + $replace = $values[1] ?? ''; + $searchKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $searchKey, $search, \PDO::PARAM_STR); + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $replaceKey, $replace, \PDO::PARAM_STR); + $bindIndex++; + break; + + // Boolean operators + case Operator::TYPE_TOGGLE: + // No parameters to bind + break; + + // Date operators + case Operator::TYPE_DATE_ADD_DAYS: + case Operator::TYPE_DATE_SUB_DAYS: + $days = $values[0] ?? 0; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $days, \PDO::PARAM_INT); + $bindIndex++; + break; + + case Operator::TYPE_DATE_SET_NOW: + // No parameters to bind + break; + + // Array operators + case Operator::TYPE_ARRAY_APPEND: + case Operator::TYPE_ARRAY_PREPEND: + // PERFORMANCE: Validate array size to prevent memory exhaustion + if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); + } + + // Bind JSON array + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $arrayValue, \PDO::PARAM_STR); + $bindIndex++; + break; + + case Operator::TYPE_ARRAY_REMOVE: + $value = $values[0] ?? null; + $bindKey = "op_{$bindIndex}"; + if (is_array($value)) { + $value = json_encode($value); + } + $stmt->bindValue(':' . $bindKey, $value, \PDO::PARAM_STR); + $bindIndex++; + break; + + case Operator::TYPE_ARRAY_UNIQUE: + // No parameters to bind + break; + + // Complex array operators + case Operator::TYPE_ARRAY_INSERT: + $index = $values[0] ?? 0; + $value = $values[1] ?? null; + $indexKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $indexKey, $index, \PDO::PARAM_INT); + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $valueKey, json_encode($value), \PDO::PARAM_STR); + $bindIndex++; + break; + + case Operator::TYPE_ARRAY_INTERSECT: + case Operator::TYPE_ARRAY_DIFF: + // PERFORMANCE: Validate array size to prevent memory exhaustion + if (\count($values) > self::MAX_ARRAY_OPERATOR_SIZE) { + throw new DatabaseException("Array size " . \count($values) . " exceeds maximum allowed size of " . self::MAX_ARRAY_OPERATOR_SIZE . " for array operations"); + } + + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $bindKey, $arrayValue, \PDO::PARAM_STR); + $bindIndex++; + break; + + case Operator::TYPE_ARRAY_FILTER: + $condition = $values[0] ?? 'equal'; + $value = $values[1] ?? null; + + // SECURITY: Whitelist validation to prevent SQL injection in CASE statements + // Support all variations used across adapters (SQL, MariaDB, Postgres, SQLite) + $validConditions = [ + 'equal', 'notEqual', 'equals', 'notEquals', // Comparison + 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual', // Numeric + 'isNull', 'isNotNull', 'null', 'notNull' // Null checks + ]; + if (!in_array($condition, $validConditions, true)) { + throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: " . implode(', ', $validConditions)); + } + + $conditionKey = "op_{$bindIndex}"; + $stmt->bindValue(':' . $conditionKey, $condition, \PDO::PARAM_STR); + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + if ($value !== null) { + $stmt->bindValue(':' . $valueKey, json_encode($value), \PDO::PARAM_STR); + } else { + $stmt->bindValue(':' . $valueKey, null, \PDO::PARAM_NULL); + } + $bindIndex++; + break; + } + } + + /** + * Apply an operator to a value (used for new documents with only operators). + * This method applies the operator logic in PHP to compute what the SQL would compute. + * + * @param Operator $operator + * @param mixed $value The current value (typically the attribute default) + * @return mixed The result after applying the operator + */ + protected function applyOperatorToValue(Operator $operator, mixed $value): mixed + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + // Numeric operators + case Operator::TYPE_INCREMENT: + return ($value ?? 0) + ($values[0] ?? 1); + + case Operator::TYPE_DECREMENT: + return ($value ?? 0) - ($values[0] ?? 1); + + case Operator::TYPE_MULTIPLY: + return ($value ?? 0) * ($values[0] ?? 1); + + case Operator::TYPE_DIVIDE: + $divisor = $values[0] ?? 1; + return $divisor != 0 ? ($value ?? 0) / $divisor : ($value ?? 0); + + case Operator::TYPE_MODULO: + $divisor = $values[0] ?? 1; + return $divisor != 0 ? ($value ?? 0) % $divisor : ($value ?? 0); + + case Operator::TYPE_POWER: + return pow($value ?? 0, $values[0] ?? 1); + + // Array operators + case Operator::TYPE_ARRAY_APPEND: + return array_merge($value ?? [], $values); + + case Operator::TYPE_ARRAY_PREPEND: + return array_merge($values, $value ?? []); + + case Operator::TYPE_ARRAY_INSERT: + $arr = $value ?? []; + $index = $values[0] ?? 0; + $item = $values[1] ?? null; + array_splice($arr, $index, 0, [$item]); + return $arr; + + case Operator::TYPE_ARRAY_REMOVE: + $arr = $value ?? []; + $toRemove = $values[0] ?? null; + if (is_array($toRemove)) { + return array_values(array_diff($arr, $toRemove)); + } + return array_values(array_diff($arr, [$toRemove])); + + case Operator::TYPE_ARRAY_UNIQUE: + return array_values(array_unique($value ?? [])); + + case Operator::TYPE_ARRAY_INTERSECT: + return array_values(array_intersect($value ?? [], $values)); + + case Operator::TYPE_ARRAY_DIFF: + return array_values(array_diff($value ?? [], $values)); + + case Operator::TYPE_ARRAY_FILTER: + return $value ?? []; + + // String operators + case Operator::TYPE_CONCAT: + return ($value ?? '') . ($values[0] ?? ''); + + case Operator::TYPE_REPLACE: + $search = $values[0] ?? ''; + $replace = $values[1] ?? ''; + return str_replace($search, $replace, $value ?? ''); + + // Boolean operators + case Operator::TYPE_TOGGLE: + return !($value ?? false); + + // Date operators + case Operator::TYPE_DATE_ADD_DAYS: + case Operator::TYPE_DATE_SUB_DAYS: + // For NULL dates, operators return NULL + return $value; + + case Operator::TYPE_DATE_SET_NOW: + return \Utopia\Database\DateTime::now(); + + default: + return $value; + } + } + /** * Returns the current PDO object * @return mixed @@ -2217,68 +2722,249 @@ public function upsertDocuments( } try { $spatialAttributes = $this->getSpatialAttributes($collection); + + // Build attribute defaults map before we lose the collection document + $attributeDefaults = []; + foreach ($collection->getAttribute('attributes', []) as $attr) { + $attributeDefaults[$attr['$id']] = $attr['default'] ?? null; + } + $collection = $collection->getId(); $name = $this->filter($collection); - $attribute = $this->filter($attribute); - $attributes = []; - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; + // Fast path: Check if ANY document has operators + $hasOperators = false; + $firstChange = $changes[0]; + $firstDoc = $firstChange->getNew(); + $firstExtracted = Operator::extractOperators($firstDoc->getAttributes()); - foreach ($changes as $change) { - $document = $change->getNew(); - $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); - - if (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); + if (!empty($firstExtracted['operators'])) { + $hasOperators = true; + } else { + // Check remaining documents + foreach ($changes as $change) { + $doc = $change->getNew(); + $extracted = Operator::extractOperators($doc->getAttributes()); + if (!empty($extracted['operators'])) { + $hasOperators = true; + break; + } } + } - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + // Fast path for non-operator upserts - ZERO overhead + if (!$hasOperators) { + // Traditional bulk upsert - original implementation + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $allColumnNames = []; + $documentsData = []; + + foreach ($changes as $change) { + $document = $change->getNew(); + $currentRegularAttributes = $document->getAttributes(); + + $currentRegularAttributes['_uid'] = $document->getId(); + $currentRegularAttributes['_createdAt'] = $document->getCreatedAt() ? \Utopia\Database\DateTime::setTimezone($document->getCreatedAt()) : null; + $currentRegularAttributes['_updatedAt'] = $document->getUpdatedAt() ? \Utopia\Database\DateTime::setTimezone($document->getUpdatedAt()) : null; + $currentRegularAttributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getSequence())) { + $currentRegularAttributes['_id'] = $document->getSequence(); + } + + if ($this->sharedTables) { + $currentRegularAttributes['_tenant'] = $document->getTenant(); + } + + foreach (\array_keys($currentRegularAttributes) as $colName) { + $allColumnNames[$colName] = true; + } + + $documentsData[] = ['regularAttributes' => $currentRegularAttributes]; } - \ksort($attributes); + $allColumnNames = \array_keys($allColumnNames); + \sort($allColumnNames); - $columns = []; - foreach (\array_keys($attributes) as $key => $attr) { - /** - * @var string $attr - */ - $columns[$key] = "{$this->quote($this->filter($attr))}"; + $columnsArray = []; + foreach ($allColumnNames as $attr) { + $columnsArray[] = "{$this->quote($this->filter($attr))}"; } - $columns = '(' . \implode(', ', $columns) . ')'; + $columns = '(' . \implode(', ', $columnsArray) . ')'; - $bindKeys = []; + foreach ($documentsData as $docData) { + $currentRegularAttributes = $docData['regularAttributes']; + $bindKeys = []; + + foreach ($allColumnNames as $attributeKey) { + $attrValue = $currentRegularAttributes[$attributeKey] ?? null; - foreach ($attributes as $attributeKey => $attrValue) { - if (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } + + if (in_array($attributeKey, $spatialAttributes) && $attrValue !== null) { + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); + } else { + $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + } + $bindValues[$bindKey] = $attrValue; + $bindIndex++; } - if (in_array($attributeKey, $spatialAttributes)) { - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + } + + $regularAttributes = []; + foreach ($allColumnNames as $colName) { + $regularAttributes[$colName] = null; + } + foreach ($documentsData[0]['regularAttributes'] as $key => $value) { + $regularAttributes[$key] = $value; + } + + $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $regularAttributes, $bindValues, $attribute, []); + $stmt->execute(); + $stmt->closeCursor(); + } else { + // Operator path: Group by operator signature and process in batches + $groups = []; + + foreach ($changes as $change) { + $document = $change->getNew(); + $extracted = Operator::extractOperators($document->getAttributes()); + $operators = $extracted['operators']; + + // Create signature for grouping + if (empty($operators)) { + $signature = 'no_ops'; } else { - $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; - $bindKey = 'key_' . $bindIndex; - $bindKeys[] = ':' . $bindKey; + $parts = []; + foreach ($operators as $attr => $op) { + $parts[] = $attr . ':' . $op->getMethod() . ':' . json_encode($op->getValues()); + } + sort($parts); + $signature = implode('|', $parts); } - $bindValues[$bindKey] = $attrValue; - $bindIndex++; + + if (!isset($groups[$signature])) { + $groups[$signature] = [ + 'documents' => [], + 'operators' => $operators + ]; + } + + $groups[$signature]['documents'][] = $change; } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; - } + // Process each group + foreach ($groups as $group) { + $groupChanges = $group['documents']; + $operators = $group['operators']; + + $bindIndex = 0; + $batchKeys = []; + $bindValues = []; + $allColumnNames = []; + $documentsData = []; + + foreach ($groupChanges as $change) { + $document = $change->getNew(); + $attributes = $document->getAttributes(); + + $extracted = Operator::extractOperators($attributes); + $currentRegularAttributes = $extracted['updates']; + $extractedOperators = $extracted['operators']; + + // For new documents, apply operators to attribute defaults + // This ensures operators work even when no regular attributes are present + if ($change->getOld()->isEmpty() && !empty($extractedOperators)) { + foreach ($extractedOperators as $operatorKey => $operator) { + $default = $attributeDefaults[$operatorKey] ?? null; + $currentRegularAttributes[$operatorKey] = $this->applyOperatorToValue($operator, $default); + } + } - $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $attributes, $bindValues, $attribute); - $stmt->execute(); - $stmt->closeCursor(); + $currentRegularAttributes['_uid'] = $document->getId(); + $currentRegularAttributes['_createdAt'] = $document->getCreatedAt() ? \Utopia\Database\DateTime::setTimezone($document->getCreatedAt()) : null; + $currentRegularAttributes['_updatedAt'] = $document->getUpdatedAt() ? \Utopia\Database\DateTime::setTimezone($document->getUpdatedAt()) : null; + $currentRegularAttributes['_permissions'] = \json_encode($document->getPermissions()); + + if (!empty($document->getSequence())) { + $currentRegularAttributes['_id'] = $document->getSequence(); + } + + if ($this->sharedTables) { + $currentRegularAttributes['_tenant'] = $document->getTenant(); + } + + foreach (\array_keys($currentRegularAttributes) as $colName) { + $allColumnNames[$colName] = true; + } + + $documentsData[] = ['regularAttributes' => $currentRegularAttributes]; + } + // Add operator columns + foreach (\array_keys($operators) as $colName) { + $allColumnNames[$colName] = true; + } + + $allColumnNames = \array_keys($allColumnNames); + \sort($allColumnNames); + + $columnsArray = []; + foreach ($allColumnNames as $attr) { + $columnsArray[] = "{$this->quote($this->filter($attr))}"; + } + $columns = '(' . \implode(', ', $columnsArray) . ')'; + + foreach ($documentsData as $docData) { + $currentRegularAttributes = $docData['regularAttributes']; + $bindKeys = []; + + foreach ($allColumnNames as $attributeKey) { + $attrValue = $currentRegularAttributes[$attributeKey] ?? null; + + if (\is_array($attrValue)) { + $attrValue = \json_encode($attrValue); + } + + if (in_array($attributeKey, $spatialAttributes) && $attrValue !== null) { + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = $this->getSpatialGeomFromText(":" . $bindKey); + } else { + $attrValue = (\is_bool($attrValue)) ? (int)$attrValue : $attrValue; + $bindKey = 'key_' . $bindIndex; + $bindKeys[] = ':' . $bindKey; + } + $bindValues[$bindKey] = $attrValue; + $bindIndex++; + } + + $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + } + + $regularAttributes = []; + foreach ($allColumnNames as $colName) { + $regularAttributes[$colName] = null; + } + foreach ($documentsData[0]['regularAttributes'] as $key => $value) { + $regularAttributes[$key] = $value; + } + + $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $regularAttributes, $bindValues, '', $operators); + $stmt->execute(); + $stmt->closeCursor(); + } + } + + // Handle permissions (unchanged) $removeQueries = []; $removeBindValues = []; $addQueries = []; @@ -2293,7 +2979,6 @@ public function upsertDocuments( $current[$type] = $old->getPermissionsByType($type); } - // Calculate removals foreach (Database::PERMISSIONS as $type) { $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); if (!empty($toRemove)) { @@ -2313,7 +2998,6 @@ public function upsertDocuments( } } - // Calculate additions foreach (Database::PERMISSIONS as $type) { $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); @@ -2336,7 +3020,6 @@ public function upsertDocuments( } } - // Execute permission removals if (!empty($removeQueries)) { $removeQuery = \implode(' OR ', $removeQueries); $stmtRemovePermissions = $this->getPDO()->prepare("DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE {$removeQuery}"); @@ -2346,7 +3029,6 @@ public function upsertDocuments( $stmtRemovePermissions->execute(); } - // Execute permission additions if (!empty($addQueries)) { $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; if ($this->sharedTables) { @@ -2366,6 +3048,7 @@ public function upsertDocuments( return \array_map(fn ($change) => $change->getNew(), $changes); } + /** * Build geometry WKT string from array input for spatial queries * diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a892b6626..fb55c6320 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -8,12 +8,14 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Duplicate; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; +use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Helpers\ID; +use Utopia\Database\Operator; /** * Main differences from MariaDB and MySQL: @@ -517,7 +519,7 @@ public function deleteIndex(string $collection, string $id): bool * @return Document * @throws Exception * @throws PDOException - * @throws Duplicate + * @throws DuplicateException */ public function createDocument(Document $collection, Document $document): Document { @@ -619,10 +621,7 @@ public function createDocument(Document $collection, Document $document): Docume $stmtPermissions->execute(); } } catch (PDOException $e) { - throw match ($e->getCode()) { - "1062", "23000" => new Duplicate('Duplicated document: ' . $e->getMessage()), - default => $e, - }; + throw $this->processException($e); } @@ -639,10 +638,11 @@ public function createDocument(Document $collection, Document $document): Docume * @return Document * @throws Exception * @throws PDOException - * @throws Duplicate + * @throws DuplicateException */ public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { + $spatialAttributes = $this->getSpatialAttributes($collection); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -794,16 +794,41 @@ public function updateDocument(Document $collection, string $id, Document $docum * Update Attributes */ $bindIndex = 0; + $operators = []; + + // Separate regular attributes from operators + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; + } + } + foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); - $bindKey = 'key_' . $bindIndex; - $columns .= "`{$column}`" . '=:' . $bindKey . ','; - $bindIndex++; + + // Check if this is an operator, spatial attribute, or regular attribute + if (isset($operators[$attribute])) { + $operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $bindIndex); + $columns .= $operatorSQL; + } elseif (\in_array($attribute, $spatialAttributes, true)) { + $bindKey = 'key_' . $bindIndex; + $columns .= "`{$column}` = " . $this->getSpatialGeomFromText(':' . $bindKey); + $bindIndex++; + } else { + $bindKey = 'key_' . $bindIndex; + $columns .= "`{$column}`" . '=:' . $bindKey; + $bindIndex++; + } + + $columns .= ','; } + // Remove trailing comma + $columns = rtrim($columns, ','); + $sql = " UPDATE `{$this->getNamespace()}_{$name}` - SET {$columns} _uid = :_newUid + SET {$columns}, _uid = :_newUid WHERE _uid = :_existingUid {$this->getTenantQuery($collection)} "; @@ -819,17 +844,28 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmt->bindValue(':_tenant', $this->tenant); } - $attributeIndex = 0; + // Bind values for non-operator attributes and operator parameters + $bindIndexForBinding = 0; foreach ($attributes as $attribute => $value) { - if (is_array($value)) { // arrays & objects should be saved as strings + // Handle operators separately + if (isset($operators[$attribute])) { + $this->bindOperatorParams($stmt, $operators[$attribute], $bindIndexForBinding); + continue; + } + + // Convert spatial arrays to WKT, json_encode non-spatial arrays + if (\in_array($attribute, $spatialAttributes, true)) { + if (\is_array($value)) { + $value = $this->convertArrayToWKT($value); + } + } elseif (is_array($value)) { // arrays & objects should be saved as strings $value = json_encode($value); } - $bindKey = 'key_' . $attributeIndex; - $attribute = $this->filter($attribute); + $bindKey = 'key_' . $bindIndexForBinding; $value = (is_bool($value)) ? (int)$value : $value; $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; + $bindIndexForBinding++; } try { @@ -841,11 +877,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $stmtAddPermissions->execute(); } } catch (PDOException $e) { - throw match ($e->getCode()) { - '1062', - '23000' => new Duplicate('Duplicated document: ' . $e->getMessage()), - default => $e, - }; + throw $this->processException($e); } return $document; @@ -1248,9 +1280,29 @@ protected function processException(PDOException $e): \Exception return new TimeoutException('Query timed out', $e->getCode(), $e); } - // Duplicate - if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1) { - return new DuplicateException('Document already exists', $e->getCode(), $e); + // Duplicate - SQLite uses various error codes for constraint violations: + // - Error code 19 is SQLITE_CONSTRAINT (includes UNIQUE violations) + // - Error code 1 is also used for some duplicate cases + // - SQL state '23000' is integrity constraint violation + if ( + ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1 || $e->errorInfo[1] === 19)) || + $e->getCode() === '23000' + ) { + // Check if it's actually a duplicate/unique constraint violation + $message = $e->getMessage(); + if ( + (isset($e->errorInfo[1]) && $e->errorInfo[1] === 19) || + $e->getCode() === '23000' || + stripos($message, 'unique') !== false || + stripos($message, 'duplicate') !== false + ) { + return new DuplicateException('Document already exists', $e->getCode(), $e); + } + } + + // String or BLOB exceeds size limit + if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 18) { + return new LimitException('Value too large', $e->getCode(), $e); } return $e; @@ -1304,4 +1356,485 @@ protected function getRandomOrder(): string { return 'RANDOM()'; } + + /** + * Check if SQLite math functions (like POWER) are available + * SQLite must be compiled with -DSQLITE_ENABLE_MATH_FUNCTIONS + * + * @return bool + */ + private function getSupportForMathFunctions(): bool + { + static $available = null; + + if ($available !== null) { + return $available; + } + + try { + // Test if POWER function exists by attempting to use it + $stmt = $this->getPDO()->query('SELECT POWER(2, 3) as test'); + $result = $stmt->fetch(); + $available = ($result['test'] == 8); + return $available; + } catch (PDOException $e) { + // Function doesn't exist + $available = false; + return false; + } + } + + /** + * Bind operator parameters to statement + * Override to handle SQLite-specific operator bindings + * + * @param \PDOStatement $stmt + * @param Operator $operator + * @param int &$bindIndex + * @return void + */ + protected function bindOperatorParams(\PDOStatement $stmt, Operator $operator, int &$bindIndex): void + { + $method = $operator->getMethod(); + + // For operators that SQLite doesn't use bind parameters for, skip binding entirely + // Note: The bindIndex increment happens in getOperatorSQL(), NOT here + if (in_array($method, [Operator::TYPE_TOGGLE, Operator::TYPE_DATE_SET_NOW, Operator::TYPE_ARRAY_UNIQUE])) { + // These operators don't bind any parameters - they're handled purely in SQL + // DO NOT increment bindIndex here as it's already handled in getOperatorSQL() + return; + } + + // For ARRAY_FILTER, bind the filter value if present + if ($method === Operator::TYPE_ARRAY_FILTER) { + $values = $operator->getValues(); + if (!empty($values) && count($values) >= 2) { + $filterType = $values[0]; + $filterValue = $values[1]; + + // Only bind if we support this filter type (all comparison operators need binding) + $comparisonTypes = ['equals', 'notEquals', 'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']; + if (in_array($filterType, $comparisonTypes)) { + $bindKey = "op_{$bindIndex}"; + $value = (is_bool($filterValue)) ? (int)$filterValue : $filterValue; + $stmt->bindValue(":{$bindKey}", $value, $this->getPDOType($value)); + $bindIndex++; + } + } + return; + } + + // For all other operators, use parent implementation + parent::bindOperatorParams($stmt, $operator, $bindIndex); + } + + /** + * Get SQL expression for operator + * + * IMPORTANT: SQLite JSON Limitations + * ----------------------------------- + * Array operators using json_each() and json_group_array() have type conversion behavior: + * - Numbers are preserved but may lose precision (e.g., 1.0 becomes 1) + * - Booleans become integers (true→1, false→0) + * - Strings remain strings + * - Objects and nested arrays are converted to JSON strings + * + * This is inherent to SQLite's JSON implementation and affects: ARRAY_APPEND, ARRAY_PREPEND, + * ARRAY_UNIQUE, ARRAY_INTERSECT, ARRAY_DIFF, ARRAY_INSERT, and ARRAY_REMOVE. + * + * @param string $column + * @param Operator $operator + * @param int &$bindIndex + * @return ?string + */ + protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex): ?string + { + $quotedColumn = $this->quote($column); + $method = $operator->getMethod(); + + switch ($method) { + case Operator::TYPE_CONCAT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = IFNULL({$quotedColumn}, '') || :$bindKey"; + + case Operator::TYPE_INCREMENT: + $values = $operator->getValues(); + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + if (isset($values[1]) && $values[1] !== null) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey + WHEN COALESCE({$quotedColumn}, 0) > :$maxKey - :$bindKey THEN :$maxKey + ELSE COALESCE({$quotedColumn}, 0) + :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) + :$bindKey"; + + case Operator::TYPE_DECREMENT: + $values = $operator->getValues(); + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + if (isset($values[1]) && $values[1] !== null) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) <= :$minKey THEN :$minKey + WHEN COALESCE({$quotedColumn}, 0) < :$minKey + :$bindKey THEN :$minKey + ELSE COALESCE({$quotedColumn}, 0) - :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) - :$bindKey"; + + case Operator::TYPE_MULTIPLY: + $values = $operator->getValues(); + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + if (isset($values[1]) && $values[1] !== null) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey + WHEN :$bindKey > 0 AND COALESCE({$quotedColumn}, 0) > :$maxKey / :$bindKey THEN :$maxKey + WHEN :$bindKey < 0 AND COALESCE({$quotedColumn}, 0) < :$maxKey / :$bindKey THEN :$maxKey + ELSE COALESCE({$quotedColumn}, 0) * :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) * :$bindKey"; + + case Operator::TYPE_DIVIDE: + $values = $operator->getValues(); + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + if (isset($values[1]) && $values[1] !== null) { + $minKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN :$bindKey != 0 AND COALESCE({$quotedColumn}, 0) / :$bindKey <= :$minKey THEN :$minKey + ELSE COALESCE({$quotedColumn}, 0) / :$bindKey + END"; + } + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) / :$bindKey"; + + case Operator::TYPE_MODULO: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = COALESCE({$quotedColumn}, 0) % :$bindKey"; + + case Operator::TYPE_POWER: + if (!$this->getSupportForMathFunctions()) { + throw new DatabaseException( + 'SQLite POWER operator requires math functions. ' . + 'Compile SQLite with -DSQLITE_ENABLE_MATH_FUNCTIONS or use multiply operators instead.' + ); + } + + $values = $operator->getValues(); + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + if (isset($values[1]) && $values[1] !== null) { + $maxKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = CASE + WHEN COALESCE({$quotedColumn}, 0) >= :$maxKey THEN :$maxKey + WHEN COALESCE({$quotedColumn}, 0) <= 1 THEN COALESCE({$quotedColumn}, 0) + WHEN :$bindKey * LN(COALESCE({$quotedColumn}, 1)) > LN(:$maxKey) THEN :$maxKey + ELSE POWER(COALESCE({$quotedColumn}, 0), :$bindKey) + END"; + } + return "{$quotedColumn} = POWER(COALESCE({$quotedColumn}, 0), :$bindKey)"; + + case Operator::TYPE_TOGGLE: + // SQLite: toggle boolean (0 or 1), treat NULL as 0 + return "{$quotedColumn} = CASE WHEN COALESCE({$quotedColumn}, 0) = 0 THEN 1 ELSE 0 END"; + + case Operator::TYPE_REPLACE: + $searchKey = "op_{$bindIndex}"; + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $bindIndex++; + return "{$quotedColumn} = REPLACE({$quotedColumn}, :$searchKey, :$replaceKey)"; + + case Operator::TYPE_ARRAY_APPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: merge arrays by using json_group_array on extracted elements + // We use json_each to extract elements from both arrays and combine them + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM ( + SELECT value FROM json_each(IFNULL({$quotedColumn}, '[]')) + UNION ALL + SELECT value FROM json_each(:$bindKey) + ) + )"; + + case Operator::TYPE_ARRAY_PREPEND: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: prepend by extracting and recombining with new elements first + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM ( + SELECT value FROM json_each(:$bindKey) + UNION ALL + SELECT value FROM json_each(IFNULL({$quotedColumn}, '[]')) + ) + )"; + + case Operator::TYPE_ARRAY_UNIQUE: + // SQLite: get distinct values from JSON array + return "{$quotedColumn} = ( + SELECT json_group_array(DISTINCT value) + FROM json_each(IFNULL({$quotedColumn}, '[]')) + )"; + + case Operator::TYPE_ARRAY_REMOVE: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: remove specific value from array + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM json_each(IFNULL({$quotedColumn}, '[]')) + WHERE value != :$bindKey + )"; + + case Operator::TYPE_ARRAY_INSERT: + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: Insert element at specific index by: + // 1. Take elements before index (0 to index-1) + // 2. Add new element + // 3. Take elements from index to end + // The bound value is JSON-encoded by parent, json() parses it back to a value, + // then we wrap it in json_array() and extract to get the same format as json_each() + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM ( + SELECT value, rownum + FROM ( + SELECT value, (ROW_NUMBER() OVER ()) - 1 as rownum + FROM json_each(IFNULL({$quotedColumn}, '[]')) + ) + WHERE rownum < :$indexKey + UNION ALL + SELECT value, :$indexKey as rownum + FROM json_each(json_array(json(:$valueKey))) + UNION ALL + SELECT value, rownum + 1 as rownum + FROM ( + SELECT value, (ROW_NUMBER() OVER ()) - 1 as rownum + FROM json_each(IFNULL({$quotedColumn}, '[]')) + ) + WHERE rownum >= :$indexKey + ORDER BY rownum + ) + )"; + + case Operator::TYPE_ARRAY_INTERSECT: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: keep only values that exist in both arrays + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM json_each(IFNULL({$quotedColumn}, '[]')) + WHERE value IN (SELECT value FROM json_each(:$bindKey)) + )"; + + case Operator::TYPE_ARRAY_DIFF: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: remove values that exist in the comparison array + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM json_each(IFNULL({$quotedColumn}, '[]')) + WHERE value NOT IN (SELECT value FROM json_each(:$bindKey)) + )"; + + case Operator::TYPE_ARRAY_FILTER: + $values = $operator->getValues(); + if (empty($values)) { + // No filter criteria, return array unchanged + return "{$quotedColumn} = {$quotedColumn}"; + } + + $filterType = $values[0]; // 'equals', 'notEquals', 'notNull', 'greaterThan', etc. + + switch ($filterType) { + case 'notNull': + // Filter out null values - no bind parameter needed + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM json_each(IFNULL({$quotedColumn}, '[]')) + WHERE value IS NOT NULL + )"; + + case 'equals': + case 'notEquals': + case 'greaterThan': + case 'greaterThanOrEqual': + case 'lessThan': + case 'lessThanOrEqual': + if (\count($values) < 2) { + return "{$quotedColumn} = {$quotedColumn}"; + } + + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + $operator = match ($filterType) { + 'equals' => '=', + 'notEquals' => '!=', + 'greaterThan' => '>', + 'greaterThanOrEqual' => '>=', + 'lessThan' => '<', + 'lessThanOrEqual' => '<=', + default => throw new OperatorException('Unsupported filter type: ' . $filterType), + }; + + // For numeric comparisons, cast to REAL; for equals/notEquals, use text comparison + $isNumericComparison = \in_array($filterType, ['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual']); + if ($isNumericComparison) { + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM json_each(IFNULL({$quotedColumn}, '[]')) + WHERE CAST(value AS REAL) $operator CAST(:$bindKey AS REAL) + )"; + } else { + return "{$quotedColumn} = ( + SELECT json_group_array(value) + FROM json_each(IFNULL({$quotedColumn}, '[]')) + WHERE value $operator :$bindKey + )"; + } + + // no break + default: + return "{$quotedColumn} = {$quotedColumn}"; + } + + // no break + case Operator::TYPE_DATE_ADD_DAYS: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + return "{$quotedColumn} = datetime({$quotedColumn}, :$bindKey || ' days')"; + + case Operator::TYPE_DATE_SUB_DAYS: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + + return "{$quotedColumn} = datetime({$quotedColumn}, '-' || abs(:$bindKey) || ' days')"; + + case Operator::TYPE_DATE_SET_NOW: + return "{$quotedColumn} = datetime('now')"; + + default: + // Fall back to parent implementation for other operators + return parent::getOperatorSQL($column, $operator, $bindIndex); + } + } + + /** + * Override getUpsertStatement to use SQLite's ON CONFLICT syntax instead of MariaDB's ON DUPLICATE KEY UPDATE + * + * @param string $tableName + * @param string $columns + * @param array $batchKeys + * @param array $attributes + * @param array $bindValues + * @param string $attribute + * @param array $operators + * @return mixed + */ + public function getUpsertStatement( + string $tableName, + string $columns, + array $batchKeys, + array $attributes, + array $bindValues, + string $attribute = '', + array $operators = [], + ): mixed { + $getUpdateClause = function (string $attribute, bool $increment = false): string { + $attribute = $this->quote($this->filter($attribute)); + if ($increment) { + $new = "{$attribute} + excluded.{$attribute}"; + } else { + $new = "excluded.{$attribute}"; + } + + if ($this->sharedTables) { + return "{$attribute} = CASE WHEN _tenant = excluded._tenant THEN {$new} ELSE {$attribute} END"; + } + + return "{$attribute} = {$new}"; + }; + + $updateColumns = []; + $bindIndex = count($bindValues); + + if (!empty($attribute)) { + // Increment specific column by its new value in place + $updateColumns = [ + $getUpdateClause($attribute, increment: true), + $getUpdateClause('_updatedAt'), + ]; + } else { + // Update all columns, handling operators separately + foreach (\array_keys($attributes) as $attr) { + /** + * @var string $attr + */ + $filteredAttr = $this->filter($attr); + + // Check if this attribute has an operator + if (isset($operators[$attr])) { + $operatorSQL = $this->getOperatorSQL($filteredAttr, $operators[$attr], $bindIndex); + if ($operatorSQL !== null) { + $updateColumns[] = $operatorSQL; + } + } else { + if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) { + $updateColumns[] = $getUpdateClause($filteredAttr); + } + } + } + } + + $conflictKeys = $this->sharedTables ? '(_uid, _tenant)' : '(_uid)'; + + $stmt = $this->getPDO()->prepare( + " + INSERT INTO {$this->getSQLTable($tableName)} {$columns} + VALUES " . \implode(', ', $batchKeys) . " + ON CONFLICT {$conflictKeys} DO UPDATE + SET " . \implode(', ', $updateColumns) + ); + + // Bind regular attribute values + foreach ($bindValues as $key => $binding) { + $stmt->bindValue($key, $binding, $this->getPDOType($binding)); + } + + $bindIndex = count($bindValues); + + // Bind operator parameters in the same order used to build SQL + foreach (array_keys($attributes) as $attr) { + if (isset($operators[$attr])) { + $this->bindOperatorParams($stmt, $operators[$attr], $bindIndex); + } + } + + return $stmt; + } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 1c6c5de0a..76c3fc25e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -27,6 +27,7 @@ use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Index as IndexValidator; use Utopia\Database\Validator\IndexDependency as IndexDependencyValidator; +use Utopia\Database\Validator\Operator as OperatorValidator; use Utopia\Database\Validator\PartialStructure; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\Queries\Document as DocumentValidator; @@ -82,6 +83,9 @@ class Database public const MAX_ARRAY_INDEX_LENGTH = 255; public const MAX_UID_DEFAULT_LENGTH = 36; + // Min limits + public const MIN_INT = -2147483648; + // Global SRID for geographic coordinates (WGS84) public const DEFAULT_SRID = 4326; public const EARTH_RADIUS = 6371000; @@ -714,6 +718,39 @@ public function skipRelationships(callable $callback): mixed } } + /** + * Refetch documents after operator updates to get computed values + * + * @param Document $collection + * @param array $documents + * @return array + */ + protected function refetchDocuments(Document $collection, array $documents): array + { + if (empty($documents)) { + return $documents; + } + + $docIds = array_map(fn ($doc) => $doc->getId(), $documents); + + // Fetch fresh copies with computed operator values + $refetched = Authorization::skip(fn () => $this->silent( + fn () => $this->find($collection->getId(), [Query::equal('$id', $docIds)]) + )); + + $refetchedMap = []; + foreach ($refetched as $doc) { + $refetchedMap[$doc->getId()] = $doc; + } + + $result = []; + foreach ($documents as $doc) { + $result[] = $refetchedMap[$doc->getId()] ?? $doc; + } + + return $result; + } + public function skipRelationshipsExistCheck(callable $callback): mixed { $previous = $this->checkRelationshipsExist; @@ -3592,9 +3629,10 @@ public function deleteIndex(string $collection, string $id): bool * @param string $collection * @param string $id * @param Query[] $queries - * + * @param bool $forUpdate * @return Document - * @throws DatabaseException + * @throws NotFoundException + * @throws QueryException * @throws Exception */ public function getDocument(string $collection, string $id, array $queries = [], bool $forUpdate = false): Document @@ -4914,7 +4952,21 @@ public function updateDocument(string $collection, string $id, Document $documen } $createdAt = $document->getCreatedAt(); - $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); + // Extract operators from the document before merging + $documentArray = $document->getArrayCopy(); + $extracted = Operator::extractOperators($documentArray); + $operators = $extracted['operators']; + $updates = $extracted['updates']; + + // Validate operators against attribute types (with current document for runtime checks) + $operatorValidator = new OperatorValidator($collection, $old); + foreach ($operators as $attribute => $operator) { + if (!$operatorValidator->isValid($operator)) { + throw new StructureException($operatorValidator->getDescription()); + } + } + + $document = \array_merge($old->getArrayCopy(), $updates); $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; @@ -4938,6 +4990,11 @@ public function updateDocument(string $collection, string $id, Document $documen $relationships[$relationship->getAttribute('key')] = $relationship; } + // If there are operators, we definitely need to update + if (!empty($operators)) { + $shouldUpdate = true; + } + // Compare if the document has any changes foreach ($document as $key => $value) { // Skip the nested documents as they will be checked later in recursions. @@ -5069,12 +5126,23 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->adapter->castingBefore($collection, $document); + // Re-add operators to document for adapter processing + foreach ($operators as $key => $operator) { + $document->setAttribute($key, $operator); + } + $this->adapter->updateDocument($collection, $id, $document, $skipPermissionsUpdate); $document = $this->adapter->castingAfter($collection, $document); $this->purgeCachedDocument($collection->getId(), $id); + // If operators were used, refetch document to get computed values + if (!empty($operators)) { + $refetched = $this->refetchDocuments($collection, [$document]); + $document = $refetched[0]; + } + return $document; }); @@ -5193,17 +5261,24 @@ public function updateDocuments( applyDefaults: false ); - // Check new document structure - $validator = new PartialStructure( - $collection, - $this->adapter->getIdAttributeType(), - $this->adapter->getMinDateTime(), - $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() - ); + // Separate operators from regular updates for validation + $extracted = Operator::extractOperators($updates->getArrayCopy()); + $operators = $extracted['operators']; + $regularUpdates = $extracted['updates']; - if (!$validator->isValid($updates)) { - throw new StructureException($validator->getDescription()); + // Only validate regular updates, not operators + if (!empty($regularUpdates)) { + $validator = new PartialStructure( + $collection, + $this->adapter->getIdAttributeType(), + $this->adapter->getMinDateTime(), + $this->adapter->getMaxDateTime(), + $this->adapter->getSupportForAttributes() + ); + + if (!$validator->isValid(new Document($regularUpdates))) { + throw new StructureException($validator->getDescription()); + } } $originalLimit = $limit; @@ -5239,8 +5314,18 @@ public function updateDocuments( $currentPermissions = $updates->getPermissions(); sort($currentPermissions); - $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions) { + $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions, $operators) { foreach ($batch as $index => $document) { + // Validate operators against attribute types (with current document for runtime checks) + if (!empty($operators)) { + $operatorValidator = new OperatorValidator($collection, $document); + foreach ($operators as $attribute => $operator) { + if (!$operatorValidator->isValid($operator)) { + throw new StructureException($operatorValidator->getDescription()); + } + } + } + $skipPermissionsUpdate = true; if ($updates->offsetExists('$permissions')) { @@ -5249,7 +5334,8 @@ public function updateDocuments( } $originalPermissions = $document->getPermissions(); - sort($originalPermissions); + + \sort($originalPermissions); $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); } @@ -5278,6 +5364,7 @@ public function updateDocuments( $batch[$index] = $this->adapter->castingBefore($collection, $encoded); } + // Adapter will handle all operators efficiently using SQL expressions $this->adapter->updateDocuments( $collection, $updates, @@ -5287,6 +5374,10 @@ public function updateDocuments( $updates = $this->adapter->castingBefore($collection, $updates); + // If operators were used, refetch documents to get computed values + if (!empty($operators)) { + $batch = $this->refetchDocuments($collection, $batch); + } foreach ($batch as $index => $doc) { $doc = $this->adapter->castingAfter($collection, $doc); @@ -5817,6 +5908,19 @@ public function upsertDocumentsWithIncrease( ))); } + // Extract operators early to avoid comparison issues + $documentArray = $document->getArrayCopy(); + $extracted = Operator::extractOperators($documentArray); + $operators = $extracted['operators']; + $regularUpdates = $extracted['updates']; + + // Filter out internal attributes from regularUpdates for comparison + $internalKeys = \array_map( + fn ($attr) => $attr['$id'], + self::INTERNAL_ATTRIBUTES + ); + $regularUpdatesUserOnly = array_diff_key($regularUpdates, array_flip($internalKeys)); + $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { @@ -5829,11 +5933,47 @@ public function upsertDocumentsWithIncrease( $skipPermissionsUpdate = ($originalPermissions === $currentPermissions); } - if ( - empty($attribute) - && $skipPermissionsUpdate - && $old->getAttributes() == $document->getAttributes() - ) { + // Only skip if no operators and regular attributes haven't changed + // Compare only the attributes that are being updated + $hasChanges = false; + if (!empty($operators)) { + $hasChanges = true; + } elseif (!empty($attribute)) { + $hasChanges = true; + } elseif (!$skipPermissionsUpdate) { + $hasChanges = true; + } else { + // Check if any of the provided attributes differ from old document + $oldAttributes = $old->getAttributes(); + foreach ($regularUpdatesUserOnly as $attrKey => $value) { + $oldValue = $oldAttributes[$attrKey] ?? null; + if ($oldValue != $value) { + $hasChanges = true; + break; + } + } + + // Also check if old document has attributes that new document doesn't + // (i.e., attributes being removed) + if (!$hasChanges) { + // Filter out internal attributes from old document for comparison + $internalKeys = \array_map( + fn ($attr) => $attr['$id'], + self::INTERNAL_ATTRIBUTES + ); + $oldUserAttributes = array_diff_key($oldAttributes, array_flip($internalKeys)); + + foreach (array_keys($oldUserAttributes) as $oldAttrKey) { + if (!array_key_exists($oldAttrKey, $regularUpdatesUserOnly)) { + // Old document has an attribute that new document doesn't + $hasChanges = true; + break; + } + } + } + } + + if (!$hasChanges) { // If not updating a single attribute and the // document is the same as the old one, skip it unset($documents[$key]); @@ -5904,7 +6044,32 @@ public function upsertDocumentsWithIncrease( } } - $document = $this->encode($collection, $document); + // Extract operators for validation - operators remain in document for adapter + $documentArray = $document->getArrayCopy(); + $extracted = Operator::extractOperators($documentArray); + $operators = $extracted['operators']; + $regularUpdates = $extracted['updates']; + + // Validate operators against attribute types + $operatorValidator = new OperatorValidator($collection, $old->isEmpty() ? null : $old); + foreach ($operators as $attribute => $operator) { + if (!$operatorValidator->isValid($operator)) { + throw new StructureException($operatorValidator->getDescription()); + } + } + + // Create a temporary document with only regular updates for encoding and validation + $tempDocument = new Document($regularUpdates); + $tempDocument->setAttribute('$id', $document->getId()); + $tempDocument->setAttribute('$collection', $document->getAttribute('$collection')); + $tempDocument->setAttribute('$createdAt', $document->getAttribute('$createdAt')); + $tempDocument->setAttribute('$updatedAt', $document->getAttribute('$updatedAt')); + $tempDocument->setAttribute('$permissions', $document->getAttribute('$permissions')); + if ($this->adapter->getSharedTables()) { + $tempDocument->setAttribute('$tenant', $document->getAttribute('$tenant')); + } + + $encodedTemp = $this->encode($collection, $tempDocument); $validator = new Structure( $collection, @@ -5914,10 +6079,13 @@ public function upsertDocumentsWithIncrease( $this->adapter->getSupportForAttributes() ); - if (!$validator->isValid($document)) { + if (!$validator->isValid($encodedTemp)) { throw new StructureException($validator->getDescription()); } + // Now encode the full document with operators for the adapter + $document = $this->encode($collection, $document); + if (!$old->isEmpty()) { // Check if document was updated after the request timestamp try { @@ -5975,9 +6143,26 @@ public function upsertDocumentsWithIncrease( $batch = $this->silent(fn () => $this->populateDocumentsRelationships($batch, $collection, $this->relationshipFetchDepth)); } + // Check if any document in the batch contains operators + $hasOperators = false; + foreach ($batch as $doc) { + $extracted = Operator::extractOperators($doc->getArrayCopy()); + if (!empty($extracted['operators'])) { + $hasOperators = true; + break; + } + } + + // If operators were used, refetch all documents in a single query + if ($hasOperators) { + $batch = $this->refetchDocuments($collection, $batch); + } + foreach ($batch as $index => $doc) { $doc = $this->adapter->castingAfter($collection, $doc); - $doc = $this->decode($collection, $doc); + if (!$hasOperators) { + $doc = $this->decode($collection, $doc); + } if ($this->getSharedTables() && $this->getTenantPerDocument()) { $this->withTenant($doc->getTenant(), function () use ($collection, $doc) { @@ -7307,6 +7492,11 @@ public function encode(Document $collection, Document $document, bool $applyDefa continue; } + // Skip encoding for Operator objects + if ($value instanceof Operator) { + continue; + } + // Assign default only if no value provided // False positive "Call to function is_null() with mixed will always evaluate to false" // @phpstan-ignore-next-line @@ -7398,6 +7588,11 @@ public function decode(Document $collection, Document $document, array $selectio } } + // Skip decoding for Operator objects (shouldn't happen, but safety check) + if ($value instanceof Operator) { + continue; + } + $value = ($array) ? $value : [$value]; $value = (is_null($value)) ? [] : $value; @@ -7516,11 +7711,12 @@ public function casting(Document $collection, Document $document): Document return $document; } + /** * Encode Attribute * * Passes the attribute $value, and $document context to a predefined filter - * that allow you to manipulate the input format of the given attribute. + * that allow you to manipulate the input format of the given attribute. * * @param string $name * @param mixed $value @@ -7557,9 +7753,9 @@ protected function encodeAttribute(string $name, mixed $value, Document $documen * @param string $filter * @param mixed $value * @param Document $document - * + * @param string $attribute * @return mixed - * @throws DatabaseException + * @throws NotFoundException */ protected function decodeAttribute(string $filter, mixed $value, Document $document, string $attribute): mixed { diff --git a/src/Database/Exception/Operator.php b/src/Database/Exception/Operator.php new file mode 100644 index 000000000..781afcb86 --- /dev/null +++ b/src/Database/Exception/Operator.php @@ -0,0 +1,9 @@ + + */ + protected array $values = []; + + /** + * Construct a new operator object + * + * @param string $method + * @param string $attribute + * @param array $values + */ + public function __construct(string $method, string $attribute = '', array $values = []) + { + $this->method = $method; + $this->attribute = $attribute; + $this->values = $values; + } + + public function __clone(): void + { + foreach ($this->values as $index => $value) { + if ($value instanceof self) { + $this->values[$index] = clone $value; + } + } + } + + /** + * @return string + */ + public function getMethod(): string + { + return $this->method; + } + + /** + * @return string + */ + public function getAttribute(): string + { + return $this->attribute; + } + + /** + * @return array + */ + public function getValues(): array + { + return $this->values; + } + + /** + * @param mixed $default + * @return mixed + */ + public function getValue(mixed $default = null): mixed + { + return $this->values[0] ?? $default; + } + + /** + * Sets method + * + * @param string $method + * @return self + */ + public function setMethod(string $method): self + { + $this->method = $method; + + return $this; + } + + /** + * Sets attribute + * + * @param string $attribute + * @return self + */ + public function setAttribute(string $attribute): self + { + $this->attribute = $attribute; + + return $this; + } + + /** + * Sets values + * + * @param array $values + * @return self + */ + public function setValues(array $values): self + { + $this->values = $values; + + return $this; + } + + /** + * Sets value + * @param mixed $value + * @return self + */ + public function setValue(mixed $value): self + { + $this->values = [$value]; + + return $this; + } + + /** + * Check if method is supported + * + * @param string $value + * @return bool + */ + public static function isMethod(string $value): bool + { + return match ($value) { + self::TYPE_INCREMENT, + self::TYPE_DECREMENT, + self::TYPE_MULTIPLY, + self::TYPE_DIVIDE, + self::TYPE_MODULO, + self::TYPE_POWER, + self::TYPE_CONCAT, + self::TYPE_REPLACE, + self::TYPE_ARRAY_APPEND, + self::TYPE_ARRAY_PREPEND, + self::TYPE_ARRAY_INSERT, + self::TYPE_ARRAY_REMOVE, + self::TYPE_ARRAY_UNIQUE, + self::TYPE_ARRAY_INTERSECT, + self::TYPE_ARRAY_DIFF, + self::TYPE_ARRAY_FILTER, + self::TYPE_TOGGLE, + self::TYPE_DATE_ADD_DAYS, + self::TYPE_DATE_SUB_DAYS, + self::TYPE_DATE_SET_NOW => true, + default => false, + }; + } + + /** + * Check if method is a numeric operation + * + * @return bool + */ + public function isNumericOperation(): bool + { + return \in_array($this->method, self::NUMERIC_TYPES); + } + + /** + * Check if method is an array operation + * + * @return bool + */ + public function isArrayOperation(): bool + { + return \in_array($this->method, self::ARRAY_TYPES); + } + + /** + * Check if method is a string operation + * + * @return bool + */ + public function isStringOperation(): bool + { + return \in_array($this->method, self::STRING_TYPES); + } + + /** + * Check if method is a boolean operation + * + * @return bool + */ + public function isBooleanOperation(): bool + { + return \in_array($this->method, self::BOOLEAN_TYPES); + } + + + /** + * Check if method is a date operation + * + * @return bool + */ + public function isDateOperation(): bool + { + return \in_array($this->method, self::DATE_TYPES); + } + + /** + * Parse operator from string + * + * @param string $operator + * @return self + * @throws OperatorException + */ + public static function parse(string $operator): self + { + try { + $operator = \json_decode($operator, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new OperatorException('Invalid operator: ' . $e->getMessage()); + } + + if (!\is_array($operator)) { + throw new OperatorException('Invalid operator. Must be an array, got ' . \gettype($operator)); + } + + return self::parseOperator($operator); + } + + /** + * Parse operator from array + * + * @param array $operator + * @return self + * @throws OperatorException + */ + public static function parseOperator(array $operator): self + { + $method = $operator['method'] ?? ''; + $attribute = $operator['attribute'] ?? ''; + $values = $operator['values'] ?? []; + + if (!\is_string($method)) { + throw new OperatorException('Invalid operator method. Must be a string, got ' . \gettype($method)); + } + + if (!self::isMethod($method)) { + throw new OperatorException('Invalid operator method: ' . $method); + } + + if (!\is_string($attribute)) { + throw new OperatorException('Invalid operator attribute. Must be a string, got ' . \gettype($attribute)); + } + + if (!\is_array($values)) { + throw new OperatorException('Invalid operator values. Must be an array, got ' . \gettype($values)); + } + + return new self($method, $attribute, $values); + } + + /** + * Parse an array of operators + * + * @param array $operators + * + * @return array + * @throws OperatorException + */ + public static function parseOperators(array $operators): array + { + $parsed = []; + + foreach ($operators as $operator) { + $parsed[] = self::parse($operator); + } + + return $parsed; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'method' => $this->method, + 'attribute' => $this->attribute, + 'values' => $this->values, + ]; + } + + /** + * @return string + * @throws OperatorException + */ + public function toString(): string + { + try { + return \json_encode($this->toArray(), flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new OperatorException('Invalid Json: ' . $e->getMessage()); + } + } + + /** + * Helper method to create increment operator + * + * @param int|float $value + * @param int|float|null $max Maximum value (won't increment beyond this) + * @return Operator + */ + public static function increment(int|float $value = 1, int|float|null $max = null): self + { + $values = [$value]; + if ($max !== null) { + $values[] = $max; + } + return new self(self::TYPE_INCREMENT, '', $values); + } + + /** + * Helper method to create decrement operator + * + * @param int|float $value + * @param int|float|null $min Minimum value (won't decrement below this) + * @return Operator + */ + public static function decrement(int|float $value = 1, int|float|null $min = null): self + { + $values = [$value]; + if ($min !== null) { + $values[] = $min; + } + return new self(self::TYPE_DECREMENT, '', $values); + } + + + /** + * Helper method to create array append operator + * + * @param array $values + * @return Operator + */ + public static function arrayAppend(array $values): self + { + return new self(self::TYPE_ARRAY_APPEND, '', $values); + } + + /** + * Helper method to create array prepend operator + * + * @param array $values + * @return Operator + */ + public static function arrayPrepend(array $values): self + { + return new self(self::TYPE_ARRAY_PREPEND, '', $values); + } + + /** + * Helper method to create array insert operator + * + * @param int $index + * @param mixed $value + * @return Operator + */ + public static function arrayInsert(int $index, mixed $value): self + { + return new self(self::TYPE_ARRAY_INSERT, '', [$index, $value]); + } + + /** + * Helper method to create array remove operator + * + * @param mixed $value + * @return Operator + */ + public static function arrayRemove(mixed $value): self + { + return new self(self::TYPE_ARRAY_REMOVE, '', [$value]); + } + + /** + * Helper method to create concatenation operator + * + * @param mixed $value Value to concatenate (string or array) + * @return Operator + */ + public static function concat(mixed $value): self + { + return new self(self::TYPE_CONCAT, '', [$value]); + } + + /** + * Helper method to create replace operator + * + * @param string $search + * @param string $replace + * @return Operator + */ + public static function replace(string $search, string $replace): self + { + return new self(self::TYPE_REPLACE, '', [$search, $replace]); + } + + /** + * Helper method to create multiply operator + * + * @param int|float $factor + * @param int|float|null $max Maximum value (won't multiply beyond this) + * @return Operator + */ + public static function multiply(int|float $factor, int|float|null $max = null): self + { + $values = [$factor]; + if ($max !== null) { + $values[] = $max; + } + return new self(self::TYPE_MULTIPLY, '', $values); + } + + /** + * Helper method to create divide operator + * + * @param int|float $divisor + * @param int|float|null $min Minimum value (won't divide below this) + * @return Operator + * @throws OperatorException if divisor is zero + */ + public static function divide(int|float $divisor, int|float|null $min = null): self + { + if ($divisor == 0) { + throw new OperatorException('Division by zero is not allowed'); + } + $values = [$divisor]; + if ($min !== null) { + $values[] = $min; + } + return new self(self::TYPE_DIVIDE, '', $values); + } + + /** + * Helper method to create toggle operator + * + * @return Operator + */ + public static function toggle(): self + { + return new self(self::TYPE_TOGGLE, '', []); + } + + + /** + * Helper method to create date add days operator + * + * @param int $days Number of days to add (can be negative to subtract) + * @return Operator + */ + public static function dateAddDays(int $days): self + { + return new self(self::TYPE_DATE_ADD_DAYS, '', [$days]); + } + + /** + * Helper method to create date subtract days operator + * + * @param int $days Number of days to subtract + * @return Operator + */ + public static function dateSubDays(int $days): self + { + return new self(self::TYPE_DATE_SUB_DAYS, '', [$days]); + } + + /** + * Helper method to create date set now operator + * + * @return Operator + */ + public static function dateSetNow(): self + { + return new self(self::TYPE_DATE_SET_NOW, '', []); + } + + /** + * Helper method to create modulo operator + * + * @param int|float $divisor The divisor for modulo operation + * @return Operator + * @throws OperatorException if divisor is zero + */ + public static function modulo(int|float $divisor): self + { + if ($divisor == 0) { + throw new OperatorException('Modulo by zero is not allowed'); + } + return new self(self::TYPE_MODULO, '', [$divisor]); + } + + /** + * Helper method to create power operator + * + * @param int|float $exponent The exponent to raise to + * @param int|float|null $max Maximum value (won't exceed this) + * @return Operator + */ + public static function power(int|float $exponent, int|float|null $max = null): self + { + $values = [$exponent]; + if ($max !== null) { + $values[] = $max; + } + return new self(self::TYPE_POWER, '', $values); + } + + + /** + * Helper method to create array unique operator + * + * @return Operator + */ + public static function arrayUnique(): self + { + return new self(self::TYPE_ARRAY_UNIQUE, '', []); + } + + /** + * Helper method to create array intersect operator + * + * @param array $values Values to intersect with current array + * @return Operator + */ + public static function arrayIntersect(array $values): self + { + return new self(self::TYPE_ARRAY_INTERSECT, '', $values); + } + + /** + * Helper method to create array diff operator + * + * @param array $values Values to remove from current array + * @return Operator + */ + public static function arrayDiff(array $values): self + { + return new self(self::TYPE_ARRAY_DIFF, '', $values); + } + + /** + * Helper method to create array filter operator + * + * @param string $condition Filter condition ('equals', 'notEquals', 'greaterThan', 'lessThan', 'null', 'notNull') + * @param mixed $value Value to filter by (not used for 'null'/'notNull' conditions) + * @return Operator + */ + public static function arrayFilter(string $condition, mixed $value = null): self + { + return new self(self::TYPE_ARRAY_FILTER, '', [$condition, $value]); + } + + /** + * Check if a value is an operator instance + * + * @param mixed $value + * @return bool + */ + public static function isOperator(mixed $value): bool + { + return $value instanceof self; + } + + /** + * Extract operators from document data + * + * @param array $data + * @return array{operators: array, updates: array} + */ + public static function extractOperators(array $data): array + { + $operators = []; + $updates = []; + + foreach ($data as $key => $value) { + if (self::isOperator($value)) { + // Set the attribute from the document key if not already set + if (empty($value->getAttribute())) { + $value->setAttribute($key); + } + $operators[$key] = $value; + } else { + $updates[$key] = $value; + } + } + + return [ + 'operators' => $operators, + 'updates' => $updates, + ]; + } + +} diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php new file mode 100644 index 000000000..6f20608dc --- /dev/null +++ b/src/Database/Validator/Operator.php @@ -0,0 +1,373 @@ +> + */ + protected array $attributes = []; + + protected string $message = 'Invalid operator'; + + protected ?Document $currentDocument = null; + + /** + * Constructor + * + * @param Document $collection + * @param Document|null $currentDocument Current document for runtime validation (e.g., array bounds checking) + */ + public function __construct(Document $collection, ?Document $currentDocument = null) + { + $this->collection = $collection; + $this->currentDocument = $currentDocument; + + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $this->attributes[$attribute->getAttribute('key', $attribute->getId())] = $attribute; + } + } + + /** + * Get Description + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return $this->message; + } + + /** + * Is valid + * + * Returns true if valid or false if not. + * + * @param $value + * + * @return bool + */ + public function isValid($value): bool + { + if (!$value instanceof DatabaseOperator) { + $this->message = 'Value must be an instance of Operator'; + return false; + } + + $method = $value->getMethod(); + $attribute = $value->getAttribute(); + + // Check if method is valid + if (!DatabaseOperator::isMethod($method)) { + $this->message = "Invalid operator method: {$method}"; + return false; + } + + // Check if attribute exists in collection + $attributeConfig = $this->attributes[$attribute] ?? null; + if ($attributeConfig === null) { + $this->message = "Attribute '{$attribute}' does not exist in collection"; + return false; + } + + // Validate operator against attribute type + return $this->validateOperatorForAttribute($value, $attributeConfig); + } + + /** + * Validate operator against attribute configuration + * + * @param DatabaseOperator $operator + * @param Document|array $attribute + * @return bool + */ + private function validateOperatorForAttribute( + DatabaseOperator $operator, + Document|array $attribute + ): bool { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + // Handle both Document objects and arrays + $type = $attribute instanceof Document ? $attribute->getAttribute('type') : $attribute['type']; + $isArray = $attribute instanceof Document ? ($attribute->getAttribute('array') ?? false) : ($attribute['array'] ?? false); + + switch ($method) { + case DatabaseOperator::TYPE_INCREMENT: + case DatabaseOperator::TYPE_DECREMENT: + case DatabaseOperator::TYPE_MULTIPLY: + case DatabaseOperator::TYPE_DIVIDE: + case DatabaseOperator::TYPE_MODULO: + case DatabaseOperator::TYPE_POWER: + // Numeric operations only work on numeric types + if (!\in_array($type, [Database::VAR_INTEGER, Database::VAR_FLOAT])) { + $this->message = "Cannot apply {$method} operator to non-numeric field '{$operator->getAttribute()}'"; + return false; + } + + // Validate the numeric value and optional max/min + if (!isset($values[0]) || !\is_numeric($values[0])) { + $this->message = "Cannot apply {$method} operator: value must be numeric, got " . gettype($operator->getValue()); + return false; + } + + // Special validation for divide/modulo by zero + if (($method === DatabaseOperator::TYPE_DIVIDE || $method === DatabaseOperator::TYPE_MODULO) && $values[0] == 0) { + $this->message = "Cannot apply {$method} operator: " . ($method === DatabaseOperator::TYPE_DIVIDE ? "division" : "modulo") . " by zero"; + return false; + } + + // Validate max/min if provided + if (\count($values) > 1 && $values[1] !== null && !\is_numeric($values[1])) { + $this->message = "Cannot apply {$method} operator: max/min limit must be numeric, got " . \gettype($values[1]); + return false; + } + + if ($this->currentDocument !== null && $type === Database::VAR_INTEGER && !isset($values[1])) { + $currentValue = $this->currentDocument->getAttribute($operator->getAttribute()) ?? 0; + $operatorValue = $values[0]; + + // Compute predicted result + $predictedResult = match ($method) { + DatabaseOperator::TYPE_INCREMENT => $currentValue + $operatorValue, + DatabaseOperator::TYPE_DECREMENT => $currentValue - $operatorValue, + DatabaseOperator::TYPE_MULTIPLY => $currentValue * $operatorValue, + DatabaseOperator::TYPE_DIVIDE => $operatorValue != 0 ? $currentValue / $operatorValue : $currentValue, + DatabaseOperator::TYPE_MODULO => $operatorValue != 0 ? $currentValue % $operatorValue : $currentValue, + DatabaseOperator::TYPE_POWER => $currentValue ** $operatorValue, + }; + + if ($predictedResult > Database::MAX_INT) { + $this->message = "Cannot apply {$method} operator: would overflow maximum value of " . Database::MAX_INT; + return false; + } + + if ($predictedResult < Database::MIN_INT) { + $this->message = "Cannot apply {$method} operator: would underflow minimum value of " . Database::MIN_INT; + return false; + } + } + + break; + case DatabaseOperator::TYPE_ARRAY_APPEND: + case DatabaseOperator::TYPE_ARRAY_PREPEND: + if (!$isArray) { + $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + return false; + } + + if (!empty($values) && $type === Database::VAR_INTEGER) { + $newItems = \is_array($values[0]) ? $values[0] : $values; + foreach ($newItems as $item) { + if (\is_numeric($item) && ($item > Database::MAX_INT || $item < Database::MIN_INT)) { + $this->message = "Cannot apply {$method} operator: array items must be between " . Database::MIN_INT . " and " . Database::MAX_INT; + return false; + } + } + } + + break; + case DatabaseOperator::TYPE_ARRAY_UNIQUE: + if (!$isArray) { + $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + return false; + } + + break; + case DatabaseOperator::TYPE_ARRAY_INSERT: + if (!$isArray) { + $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + return false; + } + + if (\count($values) !== 2) { + $this->message = "Cannot apply {$method} operator: requires exactly 2 values (index and value)"; + return false; + } + + $index = $values[0]; + if (!\is_int($index) || $index < 0) { + $this->message = "Cannot apply {$method} operator: index must be a non-negative integer"; + return false; + } + + $insertValue = $values[1]; + if ($type === Database::VAR_INTEGER && \is_numeric($insertValue)) { + if ($insertValue > Database::MAX_INT || $insertValue < Database::MIN_INT) { + $this->message = "Cannot apply {$method} operator: array items must be between " . Database::MIN_INT . " and " . Database::MAX_INT; + return false; + } + } + + // Runtime validation: Check if index is within bounds + if ($this->currentDocument !== null) { + $currentArray = $this->currentDocument->getAttribute($operator->getAttribute()); + if (\is_array($currentArray)) { + $arrayLength = \count($currentArray); + // Valid indices are 0 to length (inclusive, as we can append) + if ($index > $arrayLength) { + $this->message = "Cannot apply {$method} operator: index {$index} is out of bounds for array of length {$arrayLength}"; + return false; + } + } + } + + break; + case DatabaseOperator::TYPE_ARRAY_REMOVE: + if (!$isArray) { + $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + return false; + } + + if (empty($values)) { + $this->message = "Cannot apply {$method} operator: requires a value to remove"; + return false; + } + + break; + case DatabaseOperator::TYPE_ARRAY_INTERSECT: + if (!$isArray) { + $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; + return false; + } + + if (empty($values)) { + $this->message = "{$method} operator requires a non-empty array value"; + return false; + } + + break; + case DatabaseOperator::TYPE_ARRAY_DIFF: + if (!$isArray) { + $this->message = "Cannot use {$method} operator on non-array attribute '{$operator->getAttribute()}'"; + return false; + } + + break; + case DatabaseOperator::TYPE_ARRAY_FILTER: + if (!$isArray) { + $this->message = "Cannot apply {$method} operator to non-array field '{$operator->getAttribute()}'"; + return false; + } + + if (\count($values) < 1 || \count($values) > 2) { + $this->message = "Cannot apply {$method} operator: requires 1 or 2 values (condition and optional comparison value)"; + return false; + } + + if (!\is_string($values[0])) { + $this->message = "Cannot apply {$method} operator: condition must be a string"; + return false; + } + + break; + case DatabaseOperator::TYPE_CONCAT: + if ($type !== Database::VAR_STRING || $isArray) { + $this->message = "Cannot apply {$method} operator to non-string field '{$operator->getAttribute()}'"; + return false; + } + + if (empty($values) || !\is_string($values[0])) { + $this->message = "Cannot apply {$method} operator: requires a string value"; + return false; + } + + if ($this->currentDocument !== null && $type === Database::VAR_STRING) { + $currentString = $this->currentDocument->getAttribute($operator->getAttribute()) ?? ''; + $concatValue = $values[0]; + $predictedLength = strlen($currentString) + strlen($concatValue); + + $maxSize = $attribute instanceof Document + ? $attribute->getAttribute('size', 0) + : ($attribute['size'] ?? 0); + + if ($maxSize > 0 && $predictedLength > $maxSize) { + $this->message = "Cannot apply {$method} operator: result would exceed maximum length of {$maxSize} characters"; + return false; + } + } + + break; + case DatabaseOperator::TYPE_REPLACE: + // Replace only works on string types + if ($type !== Database::VAR_STRING) { + $this->message = "Cannot apply {$method} operator to non-string field '{$operator->getAttribute()}'"; + return false; + } + + if (\count($values) !== 2 || !\is_string($values[0]) || !\is_string($values[1])) { + $this->message = "Cannot apply {$method} operator: requires exactly 2 string values (search and replace)"; + return false; + } + + break; + case DatabaseOperator::TYPE_TOGGLE: + // Toggle only works on boolean types + if ($type !== Database::VAR_BOOLEAN) { + $this->message = "Cannot apply {$method} operator to non-boolean field '{$operator->getAttribute()}'"; + return false; + } + + break; + case DatabaseOperator::TYPE_DATE_ADD_DAYS: + case DatabaseOperator::TYPE_DATE_SUB_DAYS: + if ($type !== Database::VAR_DATETIME) { + $this->message = "Cannot apply {$method} operator to non-datetime field '{$operator->getAttribute()}'"; + return false; + } + + if (empty($values) || !\is_int($values[0])) { + $this->message = "Cannot apply {$method} operator: requires an integer number of days"; + return false; + } + + break; + case DatabaseOperator::TYPE_DATE_SET_NOW: + if ($type !== Database::VAR_DATETIME) { + $this->message = "Cannot apply {$method} operator to non-datetime field '{$operator->getAttribute()}'"; + return false; + } + + break; + default: + $this->message = "Cannot apply {$method} operator: unsupported operator method"; + return false; + } + + return true; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_OBJECT; + } +} diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 1108b2e02..cb736c287 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -34,7 +34,7 @@ public function __construct( private bool $supportForAttributes = true ) { foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); + $this->schema[$attribute->getAttribute('key', $attribute->getId())] = $attribute->getArrayCopy(); } } diff --git a/test_operator_debug.php b/test_operator_debug.php new file mode 100644 index 000000000..851047451 --- /dev/null +++ b/test_operator_debug.php @@ -0,0 +1,57 @@ +setNamespace('test'); +$cache = new Cache(new CacheNone()); +$database = new Database($dbAdapter, $cache); + +// Create database (metadata tables) +$database->create(); + +// Create collection +$collectionId = 'test_empty_strings'; +$database->createCollection($collectionId); +$database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false); + +// Create document with empty string +$doc = $database->createDocument($collectionId, new Document([ + '$id' => 'empty_str_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => '' +])); + +echo "Created document:\n"; +echo "text = '" . $doc->getAttribute('text') . "'\n"; +echo "text empty? " . (empty($doc->getAttribute('text')) ? 'YES' : 'NO') . "\n"; +echo "text === ''? " . ($doc->getAttribute('text') === '' ? 'YES' : 'NO') . "\n\n"; + +// Try to concatenate +echo "Attempting to concatenate 'hello' to empty string...\n"; +$operator = Operator::concat('hello'); +echo "Operator created: " . get_class($operator) . "\n"; +echo "Is Operator? " . (Operator::isOperator($operator) ? 'YES' : 'NO') . "\n\n"; + +$updateDoc = new Document(['text' => $operator]); +echo "Update document created\n"; +echo "Update doc text attribute: " . get_class($updateDoc->getAttribute('text')) . "\n"; +echo "Is Operator? " . (Operator::isOperator($updateDoc->getAttribute('text')) ? 'YES' : 'NO') . "\n\n"; + +// Perform update +$updated = $database->updateDocument($collectionId, 'empty_str_doc', $updateDoc); + +echo "Updated document:\n"; +echo "text = '" . $updated->getAttribute('text') . "'\n"; +echo "Expected: 'hello'\n"; +echo "Match? " . ($updated->getAttribute('text') === 'hello' ? 'YES' : 'NO') . "\n"; diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 3c44f5aa3..441125241 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -8,6 +8,7 @@ use Tests\E2E\Adapter\Scopes\DocumentTests; use Tests\E2E\Adapter\Scopes\GeneralTests; use Tests\E2E\Adapter\Scopes\IndexTests; +use Tests\E2E\Adapter\Scopes\OperatorTests; use Tests\E2E\Adapter\Scopes\PermissionTests; use Tests\E2E\Adapter\Scopes\RelationshipTests; use Tests\E2E\Adapter\Scopes\SchemalessTests; @@ -24,6 +25,7 @@ abstract class Base extends TestCase use DocumentTests; use AttributeTests; use IndexTests; + use OperatorTests; use PermissionTests; use RelationshipTests; use SpatialTests; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 311a026d9..377a85bc7 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -6,6 +6,7 @@ use Throwable; use Utopia\Database\Adapter\SQL; use Utopia\Database\Database; +use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -5424,7 +5425,10 @@ public function testEmptyTenant(): void $this->assertArrayNotHasKey('$tenant', $document); } - public function testEmptyOperatorValues(): void + /** + * @depends testCreateDocument + */ + public function testEmptyOperatorValues(Document $document): void { /** @var Database $database */ $database = static::getDatabase(); @@ -6181,427 +6185,5 @@ public function testCreateUpdateDocumentsMismatch(): void } $database->deleteCollection($colName); } - public function testDecodeWithDifferentSelectionTypes(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForRelationships()) { - $this->expectNotToPerformAssertions(); - return; - } - - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->addFilter( - 'encrypt', - function (mixed $value) { - return json_encode([ - 'data' => base64_encode($value), - 'method' => 'base64', - 'version' => 'v1', - ]); - }, - function (mixed $value) { - if (is_null($value)) { - return; - } - $value = json_decode($value, true); - return base64_decode($value['data']); - } - ); - - $citiesId = 'TestCities'; - $storesId = 'TestStores'; - - $database->createCollection($citiesId); - $database->createCollection($storesId); - - $database->createAttribute($citiesId, 'name', Database::VAR_STRING, 255, required: true); - $database->createAttribute($citiesId, 'area', Database::VAR_POLYGON, 0, required: true); - $database->createAttribute($citiesId, 'population', Database::VAR_INTEGER, 0, required: false, default: 0); - $database->createAttribute($citiesId, 'secretCode', Database::VAR_STRING, 255, required: false, default: 'default-secret', filters: ['encrypt']); - $database->createAttribute($citiesId, 'center', Database::VAR_POINT, 0, required: false, default: [0.0, 0.0]); - - $database->createAttribute($storesId, 'name', Database::VAR_STRING, 255, required: true); - $database->createAttribute($storesId, 'revenue', Database::VAR_FLOAT, 0, required: false, default: 0.0); - $database->createAttribute($storesId, 'location', Database::VAR_POINT, 0, required: false, default: [1.0, 1.0]); - - $database->createRelationship( - collection: $storesId, - relatedCollection: $citiesId, - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - id: 'city', - twoWayKey: 'stores' - ); - - $cityDoc = new Document([ - '$id' => 'city-1', - 'name' => 'Test City', - 'area' => [[[40.7128, -74.0060], [40.7589, -74.0060], [40.7589, -73.9851], [40.7128, -73.9851], [40.7128, -74.0060]]], - 'population' => 1000000, - 'secretCode' => 'super-secret-code', - 'center' => [40.7282, -73.9942], - '$permissions' => [Permission::read(Role::any())], - ]); - $createdCity = $database->createDocument($citiesId, $cityDoc); - - $storeDoc = new Document([ - '$id' => 'store-1', - 'name' => 'Main Store', - 'revenue' => 50000.75, - 'location' => [40.7300, -73.9900], - 'city' => $createdCity->getId(), - '$permissions' => [Permission::read(Role::any())], - ]); - $createdStore = $database->createDocument($storesId, $storeDoc); - - $cityWithSelection = $database->getDocument($citiesId, 'city-1', [ - Query::select(['name', 'population']) - ]); - - $this->assertEquals('Test City', $cityWithSelection->getAttribute('name')); - $this->assertEquals(1000000, $cityWithSelection->getAttribute('population')); - - $this->assertNull($cityWithSelection->getAttribute('area')); - $this->assertNull($cityWithSelection->getAttribute('secretCode')); - $this->assertNull($cityWithSelection->getAttribute('center')); - - $cityWithSpatial = $database->getDocument($citiesId, 'city-1', [ - Query::select(['name', 'area', 'center']) - ]); - - $this->assertEquals('Test City', $cityWithSpatial->getAttribute('name')); - $this->assertNotNull($cityWithSpatial->getAttribute('area')); - $this->assertEquals([[[40.7128, -74.0060], [40.7589, -74.0060], [40.7589, -73.9851], [40.7128, -73.9851], [40.7128, -74.0060]]], $cityWithSpatial->getAttribute('area')); - $this->assertEquals([40.7282, -73.9942], $cityWithSpatial->getAttribute('center')); - // Null -> not selected - $this->assertNull($cityWithSpatial->getAttribute('population')); - $this->assertNull($cityWithSpatial->getAttribute('secretCode')); - - $cityWithEncrypted = $database->getDocument($citiesId, 'city-1', [ - Query::select(['name', 'secretCode']) - ]); - - $this->assertEquals('Test City', $cityWithEncrypted->getAttribute('name')); - $this->assertEquals('super-secret-code', $cityWithEncrypted->getAttribute('secretCode')); // Should be decrypted - - $this->assertNull($cityWithEncrypted->getAttribute('area')); - $this->assertNull($cityWithEncrypted->getAttribute('population')); - - $cityWithStores = $database->getDocument($citiesId, 'city-1', [ - Query::select(['stores.name']) - ]); - - $this->assertNotNull($cityWithStores->getAttribute('stores')); - $this->assertCount(1, $cityWithStores->getAttribute('stores')); - $this->assertEquals('Main Store', $cityWithStores->getAttribute('stores')[0]['name']); - - $this->assertEquals('super-secret-code', $cityWithStores->getAttribute('secretCode')); - $this->assertNotNull($cityWithStores->getAttribute('area')); - $this->assertEquals([40.7282, -73.9942], $cityWithStores->getAttribute('center')); - - $cityWithMultipleStoreFields = $database->getDocument($citiesId, 'city-1', [ - Query::select(['stores.name', 'stores.revenue']) - ]); - - $this->assertNotNull($cityWithMultipleStoreFields->getAttribute('stores')); - $this->assertEquals('Main Store', $cityWithMultipleStoreFields->getAttribute('stores')[0]['name']); - $this->assertEquals(50000.75, $cityWithMultipleStoreFields->getAttribute('stores')[0]['revenue']); - - $this->assertEquals('super-secret-code', $cityWithMultipleStoreFields->getAttribute('secretCode')); - - $cityWithMixed = $database->getDocument($citiesId, 'city-1', [ - Query::select(['name', 'population', 'stores.name']) - ]); - - $this->assertEquals('Test City', $cityWithMixed->getAttribute('name')); - $this->assertEquals(1000000, $cityWithMixed->getAttribute('population')); - - $this->assertNotNull($cityWithMixed->getAttribute('stores')); - $this->assertEquals('Main Store', $cityWithMixed->getAttribute('stores')[0]['name']); - - $citiesWithStores = $database->find($citiesId, [ - Query::select(['stores.name']), - Query::equal('$id', ['city-1']) - ]); - - $this->assertCount(1, $citiesWithStores); - $city = $citiesWithStores[0]; - $this->assertNotNull($city->getAttribute('stores')); - $this->assertEquals('Main Store', $city->getAttribute('stores')[0]['name']); - $this->assertEquals('super-secret-code', $city->getAttribute('secretCode')); - - $storeWithCityArea = $database->getDocument($storesId, 'store-1', [ - Query::select(['location','city.area']) - ]); - - $this->assertNotNull($storeWithCityArea->getAttribute('city')); - $this->assertNotNull($storeWithCityArea->getAttribute('city')['area']); - $this->assertEquals([40.7300, -73.9900], $storeWithCityArea->getAttribute('location')); - - $database->deleteCollection($citiesId); - $database->deleteCollection($storesId); - } - - public function testDecodeWithoutRelationships(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForSpatialAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->addFilter( - 'encryptTest', - function (mixed $value) { - return 'encrypted:' . base64_encode($value); - }, - function (mixed $value) { - if (is_null($value) || !str_starts_with($value, 'encrypted:')) { - return $value; - } - return base64_decode(substr($value, 10)); - } - ); - - $collectionId = 'TestDecodeCollection'; - $database->createCollection($collectionId); - - $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 255, required: true); - $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 1000, required: false, default: 'No description'); - $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, required: false, default: 0); - $database->createAttribute($collectionId, 'price', Database::VAR_FLOAT, 0, required: false, default: 0.0); - $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, required: false, default: true); - $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, required: false, array: true); - $database->createAttribute($collectionId, 'secret', Database::VAR_STRING, 255, required: false, default: 'default-secret', filters: ['encryptTest']); - $database->createAttribute($collectionId, 'location', Database::VAR_POINT, 0, required: false, default: [0.0, 0.0]); - $database->createAttribute($collectionId, 'boundary', Database::VAR_POLYGON, 0, required: false); - - $doc = new Document([ - '$id' => 'test-1', - 'title' => 'Test Document', - 'description' => 'This is a test document', - 'count' => 42, - 'price' => 99.99, - 'active' => true, - 'tags' => ['tag1', 'tag2', 'tag3'], - 'secret' => 'my-secret-value', - 'location' => [40.7128, -74.0060], - 'boundary' => [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], - '$permissions' => [Permission::read(Role::any())], - ]); - $created = $database->createDocument($collectionId, $doc); - - $selected = $database->getDocument($collectionId, 'test-1', [ - Query::select(['title', 'count', 'secret']) - ]); - - $this->assertEquals('Test Document', $selected->getAttribute('title')); - $this->assertEquals(42, $selected->getAttribute('count')); - $this->assertEquals('my-secret-value', $selected->getAttribute('secret')); - - $this->assertNull($selected->getAttribute('description')); - $this->assertNull($selected->getAttribute('price')); - $this->assertNull($selected->getAttribute('location')); - - $spatialSelected = $database->getDocument($collectionId, 'test-1', [ - Query::select(['title', 'location', 'boundary']) - ]); - - $this->assertEquals('Test Document', $spatialSelected->getAttribute('title')); - $this->assertEquals([40.7128, -74.0060], $spatialSelected->getAttribute('location')); - $this->assertEquals([[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]], $spatialSelected->getAttribute('boundary')); - $this->assertNull($spatialSelected->getAttribute('secret')); - $this->assertNull($spatialSelected->getAttribute('count')); - $arraySelected = $database->getDocument($collectionId, 'test-1', [ - Query::select(['title', 'tags']) - ]); - - $this->assertEquals('Test Document', $arraySelected->getAttribute('title')); - $this->assertEquals(['tag1', 'tag2', 'tag3'], $arraySelected->getAttribute('tags')); - $this->assertNull($arraySelected->getAttribute('active')); - - $allSelected = $database->getDocument($collectionId, 'test-1', [ - Query::select(['*']) - ]); - - $this->assertEquals('Test Document', $allSelected->getAttribute('title')); - $this->assertEquals('This is a test document', $allSelected->getAttribute('description')); - $this->assertEquals(42, $allSelected->getAttribute('count')); - $this->assertEquals('my-secret-value', $allSelected->getAttribute('secret')); - $this->assertEquals([40.7128, -74.0060], $allSelected->getAttribute('location')); - - $noSelection = $database->getDocument($collectionId, 'test-1'); - - $this->assertEquals('Test Document', $noSelection->getAttribute('title')); - $this->assertEquals('This is a test document', $noSelection->getAttribute('description')); - $this->assertEquals('my-secret-value', $noSelection->getAttribute('secret')); - $this->assertEquals([40.7128, -74.0060], $noSelection->getAttribute('location')); - - $database->deleteCollection($collectionId); - } - - public function testDecodeWithMultipleFilters(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - $database->addFilter( - 'upperCase', - function (mixed $value) { return strtoupper($value); }, - function (mixed $value) { return strtolower($value); } - ); - - $database->addFilter( - 'prefix', - function (mixed $value) { return 'prefix_' . $value; }, - function (mixed $value) { return str_replace('prefix_', '', $value); } - ); - - $collectionId = 'EdgeCaseCollection'; - $database->createCollection($collectionId); - - $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, required: true); - $database->createAttribute($collectionId, 'processedName', Database::VAR_STRING, 255, required: false, filters: ['upperCase', 'prefix']); - $database->createAttribute($collectionId, 'nullableField', Database::VAR_STRING, 255, required: false); - - $doc = new Document([ - '$id' => 'edge-1', - 'name' => 'Test Name', - 'processedName' => 'test value', - 'nullableField' => null, - '$permissions' => [Permission::read(Role::any())], - ]); - $created = $database->createDocument($collectionId, $doc); - - $selected = $database->getDocument($collectionId, 'edge-1', [ - Query::select(['name', 'processedName']) - ]); - - $this->assertEquals('Test Name', $selected->getAttribute('name')); - $this->assertEquals('test value', $selected->getAttribute('processedName')); - $this->assertNull($selected->getAttribute('nullableField')); - - $nullSelected = $database->getDocument($collectionId, 'edge-1', [ - Query::select(['name', 'nullableField']) - ]); - - $this->assertEquals('Test Name', $nullSelected->getAttribute('name')); - $this->assertNull($nullSelected->getAttribute('nullableField')); - - $database->deleteCollection($collectionId); - } - - public function testUpdateDocumentsSuccessiveCallsDoNotResetDefaults(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collectionId = 'successive_updates'; - Authorization::cleanRoles(); - Authorization::setRole(Role::any()->toString()); - - // Create collection with two attributes that have default values - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'attrA', Database::VAR_STRING, 50, false, 'defaultA'); - $database->createAttribute($collectionId, 'attrB', Database::VAR_STRING, 50, false, 'defaultB'); - - // Create a document without setting attrA or attrB (should use defaults) - $doc = $database->createDocument($collectionId, new Document([ - '$id' => 'testdoc', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - ])); - - // Verify initial defaults - $this->assertEquals('defaultA', $doc->getAttribute('attrA')); - $this->assertEquals('defaultB', $doc->getAttribute('attrB')); - - // First update: set attrA to a new value - $count = $database->updateDocuments($collectionId, new Document([ - 'attrA' => 'updatedA', - ])); - $this->assertEquals(1, $count); - - // Verify attrA was updated - $doc = $database->getDocument($collectionId, 'testdoc'); - $this->assertEquals('updatedA', $doc->getAttribute('attrA')); - $this->assertEquals('defaultB', $doc->getAttribute('attrB')); - - // Second update: set attrB to a new value - $count = $database->updateDocuments($collectionId, new Document([ - 'attrB' => 'updatedB', - ])); - $this->assertEquals(1, $count); - - // Verify attrB was updated AND attrA is still 'updatedA' (not reset to 'defaultA') - $doc = $database->getDocument($collectionId, 'testdoc'); - $this->assertEquals('updatedA', $doc->getAttribute('attrA'), 'attrA should not be reset to default'); - $this->assertEquals('updatedB', $doc->getAttribute('attrB')); - - $database->deleteCollection($collectionId); - } - - public function testUpdateDocumentSuccessiveCallsDoNotResetDefaults(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - $collectionId = 'successive_update_single'; - Authorization::cleanRoles(); - Authorization::setRole(Role::any()->toString()); - - // Create collection with two attributes that have default values - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'attrA', Database::VAR_STRING, 50, false, 'defaultA'); - $database->createAttribute($collectionId, 'attrB', Database::VAR_STRING, 50, false, 'defaultB'); - - // Create a document without setting attrA or attrB (should use defaults) - $doc = $database->createDocument($collectionId, new Document([ - '$id' => 'testdoc', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - ])); - - // Verify initial defaults - $this->assertEquals('defaultA', $doc->getAttribute('attrA')); - $this->assertEquals('defaultB', $doc->getAttribute('attrB')); - - // First update: set attrA to a new value - $doc = $database->updateDocument($collectionId, 'testdoc', new Document([ - '$id' => 'testdoc', - 'attrA' => 'updatedA', - ])); - $this->assertEquals('updatedA', $doc->getAttribute('attrA')); - $this->assertEquals('defaultB', $doc->getAttribute('attrB')); - - // Second update: set attrB to a new value - $doc = $database->updateDocument($collectionId, 'testdoc', new Document([ - '$id' => 'testdoc', - 'attrB' => 'updatedB', - ])); - - // Verify attrB was updated AND attrA is still 'updatedA' (not reset to 'defaultA') - $this->assertEquals('updatedA', $doc->getAttribute('attrA'), 'attrA should not be reset to default'); - $this->assertEquals('updatedB', $doc->getAttribute('attrB')); - - $database->deleteCollection($collectionId); - } } diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php new file mode 100644 index 000000000..1c6f9e174 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -0,0 +1,4329 @@ +getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection with various attribute types + $collectionId = 'test_operators'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 100, false, 'test'); + + // Create test document + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 10, + 'score' => 15.5, + 'tags' => ['initial', 'tag'], + 'numbers' => [1, 2, 3], + 'name' => 'Test Document' + ])); + + // Test increment operator + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'count' => Operator::increment(5) + ])); + $this->assertEquals(15, $updated->getAttribute('count')); + + // Test decrement operator + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'count' => Operator::decrement(3) + ])); + $this->assertEquals(12, $updated->getAttribute('count')); + + // Test increment with float + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'score' => Operator::increment(2.5) + ])); + $this->assertEquals(18.0, $updated->getAttribute('score')); + + // Test append operator + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'tags' => Operator::arrayAppend(['new', 'appended']) + ])); + $this->assertEquals(['initial', 'tag', 'new', 'appended'], $updated->getAttribute('tags')); + + // Test prepend operator + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'tags' => Operator::arrayPrepend(['first']) + ])); + $this->assertEquals(['first', 'initial', 'tag', 'new', 'appended'], $updated->getAttribute('tags')); + + // Test insert operator + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'numbers' => Operator::arrayInsert(1, 99) + ])); + $this->assertEquals([1, 99, 2, 3], $updated->getAttribute('numbers')); + + // Test multiple operators in one update + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'count' => Operator::increment(8), + 'score' => Operator::decrement(3.0), + 'numbers' => Operator::arrayAppend([4, 5]), + 'name' => 'Updated Name' // Regular update mixed with operators + ])); + + $this->assertEquals(20, $updated->getAttribute('count')); + $this->assertEquals(15.0, $updated->getAttribute('score')); + $this->assertEquals([1, 99, 2, 3, 4, 5], $updated->getAttribute('numbers')); + $this->assertEquals('Updated Name', $updated->getAttribute('name')); + + // Test edge cases + + // Test increment with default value (1) + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'count' => Operator::increment() // Should increment by 1 + ])); + $this->assertEquals(21, $updated->getAttribute('count')); + + // Test insert at beginning (index 0) + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'numbers' => Operator::arrayInsert(0, 0) + ])); + $this->assertEquals([0, 1, 99, 2, 3, 4, 5], $updated->getAttribute('numbers')); + + // Test insert at end + $numbers = $updated->getAttribute('numbers'); + $lastIndex = count($numbers); + $updated = $database->updateDocument($collectionId, 'test_doc', new Document([ + 'numbers' => Operator::arrayInsert($lastIndex, 100) + ])); + $this->assertEquals([0, 1, 99, 2, 3, 4, 5, 100], $updated->getAttribute('numbers')); + + $database->deleteCollection($collectionId); + } + + public function testUpdateDocumentsWithOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_batch_operators'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'category', Database::VAR_STRING, 50, true); + + // Create multiple test documents + $docs = []; + for ($i = 1; $i <= 3; $i++) { + $docs[] = $database->createDocument($collectionId, new Document([ + '$id' => "doc_{$i}", + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => $i * 10, + 'tags' => ["tag_{$i}"], + 'category' => 'test' + ])); + } + + // Test updateDocuments with operators + $count = $database->updateDocuments( + $collectionId, + new Document([ + 'count' => Operator::increment(5), + 'tags' => Operator::arrayAppend(['batch_updated']), + 'category' => 'updated' // Regular update mixed with operators + ]) + ); + + $this->assertEquals(3, $count); + + // Verify all documents were updated + $updated = $database->find($collectionId); + $this->assertCount(3, $updated); + + foreach ($updated as $doc) { + $originalCount = (int) str_replace('doc_', '', $doc->getId()) * 10; + $this->assertEquals($originalCount + 5, $doc->getAttribute('count')); + $this->assertContains('batch_updated', $doc->getAttribute('tags')); + $this->assertEquals('updated', $doc->getAttribute('category')); + } + + // Test with query filters + $count = $database->updateDocuments( + $collectionId, + new Document([ + 'count' => Operator::increment(10) + ]), + [Query::equal('$id', ['doc_1', 'doc_2'])] + ); + + $this->assertEquals(2, $count); + + // Verify only filtered documents were updated + $doc1 = $database->getDocument($collectionId, 'doc_1'); + $doc2 = $database->getDocument($collectionId, 'doc_2'); + $doc3 = $database->getDocument($collectionId, 'doc_3'); + + $this->assertEquals(25, $doc1->getAttribute('count')); // 10 + 5 + 10 + $this->assertEquals(35, $doc2->getAttribute('count')); // 20 + 5 + 10 + $this->assertEquals(35, $doc3->getAttribute('count')); // 30 + 5 (not updated in second batch) + + $database->deleteCollection($collectionId); + } + + public function testUpdateDocumentsWithAllOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create comprehensive test collection + $collectionId = 'test_all_operators_bulk'; + $database->createCollection($collectionId); + + // Create attributes for all operator types + $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 10); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 5.0); + $database->createAttribute($collectionId, 'multiplier', Database::VAR_FLOAT, 0, false, 2.0); + $database->createAttribute($collectionId, 'divisor', Database::VAR_FLOAT, 0, false, 100.0); + $database->createAttribute($collectionId, 'remainder', Database::VAR_INTEGER, 0, false, 20); + $database->createAttribute($collectionId, 'power_val', Database::VAR_FLOAT, 0, false, 2.0); + $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 255, false, 'Title'); + $database->createAttribute($collectionId, 'content', Database::VAR_STRING, 500, false, 'old content'); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'categories', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'duplicates', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false, false); + $database->createAttribute($collectionId, 'last_update', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createAttribute($collectionId, 'next_update', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + $database->createAttribute($collectionId, 'now_field', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + + // Create test documents + $docs = []; + for ($i = 1; $i <= 3; $i++) { + $docs[] = $database->createDocument($collectionId, new Document([ + '$id' => "bulk_doc_{$i}", + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => $i * 10, + 'score' => $i * 1.5, + 'multiplier' => $i * 1.0, + 'divisor' => $i * 50.0, + 'remainder' => $i * 7, + 'power_val' => $i + 1.0, + 'title' => "Title {$i}", + 'content' => "old content {$i}", + 'tags' => ["tag_{$i}", "common"], + 'categories' => ["cat_{$i}", "test"], + 'items' => ["item_{$i}", "shared", "item_{$i}"], + 'duplicates' => ["a", "b", "a", "c", "b", "d"], + 'active' => $i % 2 === 0, + 'last_update' => DateTime::addSeconds(new \DateTime(), -86400), + 'next_update' => DateTime::addSeconds(new \DateTime(), 86400) + ])); + } + + // Test bulk update with ALL operators + $count = $database->updateDocuments( + $collectionId, + new Document([ + 'counter' => Operator::increment(5, 50), // Math with limit + 'score' => Operator::decrement(0.5, 0), // Math with limit + 'multiplier' => Operator::multiply(2, 100), // Math with limit + 'divisor' => Operator::divide(2, 10), // Math with limit + 'remainder' => Operator::modulo(5), // Math + 'power_val' => Operator::power(2, 100), // Math with limit + 'title' => Operator::concat(' - Updated'), // String + 'content' => Operator::replace('old', 'new'), // String + 'tags' => Operator::arrayAppend(['bulk']), // Array + 'categories' => Operator::arrayPrepend(['priority']), // Array + 'items' => Operator::arrayRemove('shared'), // Array + 'duplicates' => Operator::arrayUnique(), // Array + 'active' => Operator::toggle(), // Boolean + 'last_update' => Operator::dateAddDays(1), // Date + 'next_update' => Operator::dateSubDays(1), // Date + 'now_field' => Operator::dateSetNow() // Date + ]) + ); + + $this->assertEquals(3, $count); + + // Verify all operators worked correctly + $updated = $database->find($collectionId, [Query::orderAsc('$id')]); + $this->assertCount(3, $updated); + + // Check bulk_doc_1 + $doc1 = $updated[0]; + $this->assertEquals(15, $doc1->getAttribute('counter')); // 10 + 5 + $this->assertEquals(1.0, $doc1->getAttribute('score')); // 1.5 - 0.5 + $this->assertEquals(2.0, $doc1->getAttribute('multiplier')); // 1.0 * 2 + $this->assertEquals(25.0, $doc1->getAttribute('divisor')); // 50.0 / 2 + $this->assertEquals(2, $doc1->getAttribute('remainder')); // 7 % 5 + $this->assertEquals(4.0, $doc1->getAttribute('power_val')); // 2^2 + $this->assertEquals('Title 1 - Updated', $doc1->getAttribute('title')); + $this->assertEquals('new content 1', $doc1->getAttribute('content')); + $this->assertContains('bulk', $doc1->getAttribute('tags')); + $this->assertContains('priority', $doc1->getAttribute('categories')); + $this->assertNotContains('shared', $doc1->getAttribute('items')); + $this->assertCount(4, $doc1->getAttribute('duplicates')); // Should have unique values + $this->assertEquals(true, $doc1->getAttribute('active')); // Was false, toggled to true + + // Check bulk_doc_2 + $doc2 = $updated[1]; + $this->assertEquals(25, $doc2->getAttribute('counter')); // 20 + 5 + $this->assertEquals(2.5, $doc2->getAttribute('score')); // 3.0 - 0.5 + $this->assertEquals(4.0, $doc2->getAttribute('multiplier')); // 2.0 * 2 + $this->assertEquals(50.0, $doc2->getAttribute('divisor')); // 100.0 / 2 + $this->assertEquals(4, $doc2->getAttribute('remainder')); // 14 % 5 + $this->assertEquals(9.0, $doc2->getAttribute('power_val')); // 3^2 + $this->assertEquals('Title 2 - Updated', $doc2->getAttribute('title')); + $this->assertEquals('new content 2', $doc2->getAttribute('content')); + $this->assertEquals(false, $doc2->getAttribute('active')); // Was true, toggled to false + + // Check bulk_doc_3 + $doc3 = $updated[2]; + $this->assertEquals(35, $doc3->getAttribute('counter')); // 30 + 5 + $this->assertEquals(4.0, $doc3->getAttribute('score')); // 4.5 - 0.5 + $this->assertEquals(6.0, $doc3->getAttribute('multiplier')); // 3.0 * 2 + $this->assertEquals(75.0, $doc3->getAttribute('divisor')); // 150.0 / 2 + $this->assertEquals(1, $doc3->getAttribute('remainder')); // 21 % 5 + $this->assertEquals(16.0, $doc3->getAttribute('power_val')); // 4^2 + $this->assertEquals('Title 3 - Updated', $doc3->getAttribute('title')); + $this->assertEquals('new content 3', $doc3->getAttribute('content')); + $this->assertEquals(true, $doc3->getAttribute('active')); // Was false, toggled to true + + // Verify date operations worked (just check they're not null and are strings) + $this->assertNotNull($doc1->getAttribute('last_update')); + $this->assertNotNull($doc1->getAttribute('next_update')); + $this->assertNotNull($doc1->getAttribute('now_field')); + + $database->deleteCollection($collectionId); + } + + public function testUpdateDocumentsOperatorsWithQueries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_operators_with_queries'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'category', Database::VAR_STRING, 50, true); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false, false); + + // Create test documents + for ($i = 1; $i <= 5; $i++) { + $database->createDocument($collectionId, new Document([ + '$id' => "query_doc_{$i}", + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'category' => $i <= 3 ? 'A' : 'B', + 'count' => $i * 10, + 'score' => $i * 1.5, + 'active' => $i % 2 === 0 + ])); + } + + // Test 1: Update only category A documents + $count = $database->updateDocuments( + $collectionId, + new Document([ + 'count' => Operator::increment(100), + 'score' => Operator::multiply(2) + ]), + [Query::equal('category', ['A'])] + ); + + $this->assertEquals(3, $count); + + // Verify only category A documents were updated + $categoryA = $database->find($collectionId, [Query::equal('category', ['A']), Query::orderAsc('$id')]); + $categoryB = $database->find($collectionId, [Query::equal('category', ['B']), Query::orderAsc('$id')]); + + $this->assertEquals(110, $categoryA[0]->getAttribute('count')); // 10 + 100 + $this->assertEquals(120, $categoryA[1]->getAttribute('count')); // 20 + 100 + $this->assertEquals(130, $categoryA[2]->getAttribute('count')); // 30 + 100 + $this->assertEquals(40, $categoryB[0]->getAttribute('count')); // Not updated + $this->assertEquals(50, $categoryB[1]->getAttribute('count')); // Not updated + + // Test 2: Update only documents with count < 50 + $count = $database->updateDocuments( + $collectionId, + new Document([ + 'active' => Operator::toggle(), + 'score' => Operator::multiply(10) + ]), + [Query::lessThan('count', 50)] + ); + + // Only doc_4 (count=40) matches, doc_5 has count=50 which is not < 50 + $this->assertEquals(1, $count); + + $doc4 = $database->getDocument($collectionId, 'query_doc_4'); + $this->assertEquals(false, $doc4->getAttribute('active')); // Was true, now false + // Doc_4 initial score: 4*1.5 = 6.0 + // Category B so not updated in first batch + // Second update: 6.0 * 10 = 60.0 + $this->assertEquals(60.0, $doc4->getAttribute('score')); + + // Verify doc_5 was not updated + $doc5 = $database->getDocument($collectionId, 'query_doc_5'); + $this->assertEquals(false, $doc5->getAttribute('active')); // Still false + $this->assertEquals(7.5, $doc5->getAttribute('score')); // Still 5*1.5=7.5 (category B, not updated) + + $database->deleteCollection($collectionId); + } + + public function testOperatorErrorHandling(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_operator_errors'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'text_field', Database::VAR_STRING, 100, true); + $database->createAttribute($collectionId, 'number_field', Database::VAR_INTEGER, 0, true); + $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); + + // Create test document + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'error_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text_field' => 'hello', + 'number_field' => 42, + 'array_field' => ['item1', 'item2'] + ])); + + // Test increment on non-numeric field + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage("Cannot apply increment operator to non-numeric field 'text_field'"); + + $database->updateDocument($collectionId, 'error_test_doc', new Document([ + 'text_field' => Operator::increment(1) + ])); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayErrorHandling(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_array_operator_errors'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'text_field', Database::VAR_STRING, 100, true); + $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); + + // Create test document + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'array_error_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text_field' => 'hello', + 'array_field' => ['item1', 'item2'] + ])); + + // Test append on non-array field + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage("Cannot apply arrayAppend operator to non-array field 'text_field'"); + + $database->updateDocument($collectionId, 'array_error_test_doc', new Document([ + 'text_field' => Operator::arrayAppend(['new_item']) + ])); + + $database->deleteCollection($collectionId); + } + + public function testOperatorInsertErrorHandling(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_insert_operator_errors'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); + + // Create test document + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'insert_error_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'array_field' => ['item1', 'item2'] + ])); + + // Test insert with negative index + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage("Cannot apply arrayInsert operator: index must be a non-negative integer"); + + $database->updateDocument($collectionId, 'insert_error_test_doc', new Document([ + 'array_field' => Operator::arrayInsert(-1, 'new_item') + ])); + + $database->deleteCollection($collectionId); + } + + /** + * Comprehensive edge case tests for operator validation failures + */ + public function testOperatorValidationEdgeCases(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create comprehensive test collection + $collectionId = 'test_operator_edge_cases'; + $database->createCollection($collectionId); + + // Create various attribute types for testing + $database->createAttribute($collectionId, 'string_field', Database::VAR_STRING, 100, false, 'default'); + $database->createAttribute($collectionId, 'int_field', Database::VAR_INTEGER, 0, false, 10); + $database->createAttribute($collectionId, 'float_field', Database::VAR_FLOAT, 0, false, 1.5); + $database->createAttribute($collectionId, 'bool_field', Database::VAR_BOOLEAN, 0, false, false); + $database->createAttribute($collectionId, 'array_field', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'date_field', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + + // Create test document + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'edge_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'string_field' => 'hello', + 'int_field' => 42, + 'float_field' => 3.14, + 'bool_field' => true, + 'array_field' => ['a', 'b', 'c'], + 'date_field' => '2023-01-01 00:00:00' + ])); + + // Test: Math operator on string field + try { + $database->updateDocument($collectionId, 'edge_test_doc', new Document([ + 'string_field' => Operator::increment(5) + ])); + $this->fail('Expected exception for increment on string field'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Cannot apply increment operator to non-numeric field 'string_field'", $e->getMessage()); + } + + // Test: String operator on numeric field + try { + $database->updateDocument($collectionId, 'edge_test_doc', new Document([ + 'int_field' => Operator::concat(' suffix') + ])); + $this->fail('Expected exception for concat on integer field'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Cannot apply concat operator", $e->getMessage()); + } + + // Test: Array operator on non-array field + try { + $database->updateDocument($collectionId, 'edge_test_doc', new Document([ + 'string_field' => Operator::arrayAppend(['new']) + ])); + $this->fail('Expected exception for arrayAppend on string field'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Cannot apply arrayAppend operator to non-array field 'string_field'", $e->getMessage()); + } + + // Test: Boolean operator on non-boolean field + try { + $database->updateDocument($collectionId, 'edge_test_doc', new Document([ + 'int_field' => Operator::toggle() + ])); + $this->fail('Expected exception for toggle on integer field'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Cannot apply toggle operator to non-boolean field 'int_field'", $e->getMessage()); + } + + // Test: Date operator on non-date field + try { + $database->updateDocument($collectionId, 'edge_test_doc', new Document([ + 'string_field' => Operator::dateAddDays(5) + ])); + $this->fail('Expected exception for dateAddDays on string field'); + } catch (DatabaseException $e) { + // Date operators check if string can be parsed as date + $this->assertStringContainsString("Cannot apply dateAddDays operator to non-datetime field 'string_field'", $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + + public function testOperatorDivisionModuloByZero(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_division_zero'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'number', Database::VAR_FLOAT, 0, false, 100.0); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'zero_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'number' => 100.0 + ])); + + // Test: Division by zero + try { + $database->updateDocument($collectionId, 'zero_test_doc', new Document([ + 'number' => Operator::divide(0) + ])); + $this->fail('Expected exception for division by zero'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Division by zero is not allowed", $e->getMessage()); + } + + // Test: Modulo by zero + try { + $database->updateDocument($collectionId, 'zero_test_doc', new Document([ + 'number' => Operator::modulo(0) + ])); + $this->fail('Expected exception for modulo by zero'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Modulo by zero is not allowed", $e->getMessage()); + } + + // Test: Valid division + $updated = $database->updateDocument($collectionId, 'zero_test_doc', new Document([ + 'number' => Operator::divide(2) + ])); + $this->assertEquals(50.0, $updated->getAttribute('number')); + + // Test: Valid modulo + $updated = $database->updateDocument($collectionId, 'zero_test_doc', new Document([ + 'number' => Operator::modulo(7) + ])); + $this->assertEquals(1.0, $updated->getAttribute('number')); // 50 % 7 = 1 + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayInsertOutOfBounds(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_insert_bounds'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'bounds_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c'] // Length = 3 + ])); + + // Test: Insert at out of bounds index + try { + $database->updateDocument($collectionId, 'bounds_test_doc', new Document([ + 'items' => Operator::arrayInsert(10, 'new') // Index 10 > length 3 + ])); + $this->fail('Expected exception for out of bounds insert'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Cannot apply arrayInsert operator: index 10 is out of bounds for array of length 3", $e->getMessage()); + } + + // Test: Insert at valid index (end) + $updated = $database->updateDocument($collectionId, 'bounds_test_doc', new Document([ + 'items' => Operator::arrayInsert(3, 'd') // Insert at end + ])); + $this->assertEquals(['a', 'b', 'c', 'd'], $updated->getAttribute('items')); + + // Test: Insert at valid index (middle) + $updated = $database->updateDocument($collectionId, 'bounds_test_doc', new Document([ + 'items' => Operator::arrayInsert(2, 'x') // Insert at index 2 + ])); + $this->assertEquals(['a', 'b', 'x', 'c', 'd'], $updated->getAttribute('items')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorValueLimits(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_operator_limits'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 10); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 5.0); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'limits_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 10, + 'score' => 5.0 + ])); + + // Test: Increment with max limit + $updated = $database->updateDocument($collectionId, 'limits_test_doc', new Document([ + 'counter' => Operator::increment(100, 50) // Increment by 100 but max is 50 + ])); + $this->assertEquals(50, $updated->getAttribute('counter')); // Should be capped at 50 + + // Test: Decrement with min limit + $updated = $database->updateDocument($collectionId, 'limits_test_doc', new Document([ + 'score' => Operator::decrement(10, 0) // Decrement score by 10 but min is 0 + ])); + $this->assertEquals(0, $updated->getAttribute('score')); // Should be capped at 0 + + // Test: Multiply with max limit + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'limits_test_doc2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 10, + 'score' => 5.0 + ])); + + $updated = $database->updateDocument($collectionId, 'limits_test_doc2', new Document([ + 'counter' => Operator::multiply(10, 75) // 10 * 10 = 100, but max is 75 + ])); + $this->assertEquals(75, $updated->getAttribute('counter')); // Should be capped at 75 + + // Test: Power with max limit + $updated = $database->updateDocument($collectionId, 'limits_test_doc2', new Document([ + 'score' => Operator::power(3, 100) // 5^3 = 125, but max is 100 + ])); + $this->assertEquals(100, $updated->getAttribute('score')); // Should be capped at 100 + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayFilterValidation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_filter'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'filter_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [1, 2, 3, 4, 5], + 'tags' => ['apple', 'banana', 'cherry'] + ])); + + // Test: Filter with equals condition on numbers + $updated = $database->updateDocument($collectionId, 'filter_test_doc', new Document([ + 'numbers' => Operator::arrayFilter('equals', 3) // Keep only 3 + ])); + $this->assertEquals([3], $updated->getAttribute('numbers')); + + // Test: Filter with not-equals condition on strings + $updated = $database->updateDocument($collectionId, 'filter_test_doc', new Document([ + 'tags' => Operator::arrayFilter('notEquals', 'banana') // Remove 'banana' + ])); + $this->assertEquals(['apple', 'cherry'], $updated->getAttribute('tags')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorReplaceValidation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_replace'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, 'default text'); + $database->createAttribute($collectionId, 'number', Database::VAR_INTEGER, 0, false, 0); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'replace_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => 'The quick brown fox', + 'number' => 42 + ])); + + // Test: Valid replace operation + $updated = $database->updateDocument($collectionId, 'replace_test_doc', new Document([ + 'text' => Operator::replace('quick', 'slow') + ])); + $this->assertEquals('The slow brown fox', $updated->getAttribute('text')); + + // Test: Replace on non-string field + try { + $database->updateDocument($collectionId, 'replace_test_doc', new Document([ + 'number' => Operator::replace('4', '5') + ])); + $this->fail('Expected exception for replace on integer field'); + } catch (DatabaseException $e) { + $this->assertStringContainsString("Cannot apply replace operator to non-string field 'number'", $e->getMessage()); + } + + // Test: Replace with empty string + $updated = $database->updateDocument($collectionId, 'replace_test_doc', new Document([ + 'text' => Operator::replace('slow', '') + ])); + $this->assertEquals('The brown fox', $updated->getAttribute('text')); // Two spaces where 'slow' was + + $database->deleteCollection($collectionId); + } + + public function testOperatorNullValueHandling(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_null_handling'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'nullable_int', Database::VAR_INTEGER, 0, false, null, false, false); + $database->createAttribute($collectionId, 'nullable_string', Database::VAR_STRING, 100, false, null, false, false); + $database->createAttribute($collectionId, 'nullable_bool', Database::VAR_BOOLEAN, 0, false, null, false, false); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'null_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'nullable_int' => null, + 'nullable_string' => null, + 'nullable_bool' => null + ])); + + // Test: Increment on null numeric field (should treat as 0) + $updated = $database->updateDocument($collectionId, 'null_test_doc', new Document([ + 'nullable_int' => Operator::increment(5) + ])); + $this->assertEquals(5, $updated->getAttribute('nullable_int')); + + // Test: Concat on null string field (should treat as empty string) + $updated = $database->updateDocument($collectionId, 'null_test_doc', new Document([ + 'nullable_string' => Operator::concat('hello') + ])); + $this->assertEquals('hello', $updated->getAttribute('nullable_string')); + + // Test: Toggle on null boolean field (should treat as false) + $updated = $database->updateDocument($collectionId, 'null_test_doc', new Document([ + 'nullable_bool' => Operator::toggle() + ])); + $this->assertEquals(true, $updated->getAttribute('nullable_bool')); + + // Test operators on non-null values + $updated = $database->updateDocument($collectionId, 'null_test_doc', new Document([ + 'nullable_int' => Operator::multiply(2) // 5 * 2 = 10 + ])); + $this->assertEquals(10, $updated->getAttribute('nullable_int')); + + $updated = $database->updateDocument($collectionId, 'null_test_doc', new Document([ + 'nullable_string' => Operator::replace('hello', 'hi') + ])); + $this->assertEquals('hi', $updated->getAttribute('nullable_string')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorComplexScenarios(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_complex_operators'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'stats', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createAttribute($collectionId, 'metadata', Database::VAR_STRING, 100, false, null, true, true); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false, ''); + + // Create document with complex data + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'complex_test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'stats' => [10, 20, 20, 30, 20, 40], + 'metadata' => ['key1', 'key2', 'key3'], + 'score' => 50.0, + 'name' => 'Test' + ])); + + // Test: Multiple operations on same array + $updated = $database->updateDocument($collectionId, 'complex_test_doc', new Document([ + 'stats' => Operator::arrayUnique() // Should remove duplicate 20s + ])); + $stats = $updated->getAttribute('stats'); + $this->assertCount(4, $stats); // [10, 20, 30, 40] + $this->assertEquals([10, 20, 30, 40], $stats); + + // Test: Array intersection + $updated = $database->updateDocument($collectionId, 'complex_test_doc', new Document([ + 'stats' => Operator::arrayIntersect([20, 30, 50]) // Keep only 20 and 30 + ])); + $this->assertEquals([20, 30], $updated->getAttribute('stats')); + + // Test: Array difference + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'complex_test_doc2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'stats' => [1, 2, 3, 4, 5], + 'metadata' => ['a', 'b', 'c'], + 'score' => 100.0, + 'name' => 'Test2' + ])); + + $updated = $database->updateDocument($collectionId, 'complex_test_doc2', new Document([ + 'stats' => Operator::arrayDiff([2, 4, 6]) // Remove 2 and 4 + ])); + $this->assertEquals([1, 3, 5], $updated->getAttribute('stats')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorIncrement(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_increment_operator'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 5 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'count' => Operator::increment(3) + ])); + + $this->assertEquals(8, $updated->getAttribute('count')); + + // Edge case: null value + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => null + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'count' => Operator::increment(3) + ])); + + $this->assertEquals(3, $updated->getAttribute('count')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorStringConcat(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_string_concat_operator'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 255, false, ''); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Hello' + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'title' => Operator::concat(' World') + ])); + + $this->assertEquals('Hello World', $updated->getAttribute('title')); + + // Edge case: null value + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => null + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'title' => Operator::concat('Test') + ])); + + $this->assertEquals('Test', $updated->getAttribute('title')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorModulo(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_modulo_operator'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'number', Database::VAR_INTEGER, 0, false, 0); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'number' => 10 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'number' => Operator::modulo(3) + ])); + + $this->assertEquals(1, $updated->getAttribute('number')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorToggle(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_toggle_operator'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false, false); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'active' => false + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'active' => Operator::toggle() + ])); + + $this->assertEquals(true, $updated->getAttribute('active')); + + // Test toggle again + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'active' => Operator::toggle() + ])); + + $this->assertEquals(false, $updated->getAttribute('active')); + + $database->deleteCollection($collectionId); + } + + + public function testOperatorArrayUnique(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_unique_operator'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'a', 'c', 'b'] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayUnique() + ])); + + $result = $updated->getAttribute('items'); + $this->assertCount(3, $result); + $this->assertContains('a', $result); + $this->assertContains('b', $result); + $this->assertContains('c', $result); + + $database->deleteCollection($collectionId); + } + + // Comprehensive Operator Tests + + public function testOperatorIncrementComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Setup collection + $collectionId = 'operator_increment_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false); + + // Success case - integer + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 5 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'count' => Operator::increment(3) + ])); + + $this->assertEquals(8, $updated->getAttribute('count')); + + // Success case - with max limit + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'count' => Operator::increment(5, 10) + ])); + $this->assertEquals(10, $updated->getAttribute('count')); // Should cap at 10 + + // Success case - float + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'score' => 2.5 + ])); + + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'score' => Operator::increment(1.5) + ])); + $this->assertEquals(4.0, $updated->getAttribute('score')); + + // Edge case: null value + $doc3 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => null + ])); + $updated = $database->updateDocument($collectionId, $doc3->getId(), new Document([ + 'count' => Operator::increment(5) + ])); + $this->assertEquals(5, $updated->getAttribute('count')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorDecrementComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_decrement_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 10 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'count' => Operator::decrement(3) + ])); + + $this->assertEquals(7, $updated->getAttribute('count')); + + // Success case - with min limit + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'count' => Operator::decrement(10, 5) + ])); + $this->assertEquals(5, $updated->getAttribute('count')); // Should stop at min 5 + + // Edge case: null value + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => null + ])); + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'count' => Operator::decrement(3) + ])); + $this->assertEquals(-3, $updated->getAttribute('count')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorMultiplyComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_multiply_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 4.0 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'value' => Operator::multiply(2.5) + ])); + + $this->assertEquals(10.0, $updated->getAttribute('value')); + + // Success case - with max limit + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'value' => Operator::multiply(3, 20) + ])); + $this->assertEquals(20.0, $updated->getAttribute('value')); // Should cap at 20 + + $database->deleteCollection($collectionId); + } + + public function testOperatorDivideComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_divide_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 10.0 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'value' => Operator::divide(2) + ])); + + $this->assertEquals(5.0, $updated->getAttribute('value')); + + // Success case - with min limit + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'value' => Operator::divide(10, 2) + ])); + $this->assertEquals(2.0, $updated->getAttribute('value')); // Should stop at min 2 + + $database->deleteCollection($collectionId); + } + + public function testOperatorModuloComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_modulo_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'number', Database::VAR_INTEGER, 0, false); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'number' => 10 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'number' => Operator::modulo(3) + ])); + + $this->assertEquals(1, $updated->getAttribute('number')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorPowerComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_power_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'number', Database::VAR_FLOAT, 0, false); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'number' => 2 + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'number' => Operator::power(3) + ])); + + $this->assertEquals(8, $updated->getAttribute('number')); + + // Success case - with max limit + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'number' => Operator::power(4, 50) + ])); + $this->assertEquals(50, $updated->getAttribute('number')); // Should cap at 50 + + $database->deleteCollection($collectionId); + } + + public function testOperatorStringConcatComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_concat_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => 'Hello' + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'text' => Operator::concat(' World') + ])); + + $this->assertEquals('Hello World', $updated->getAttribute('text')); + + // Edge case: null value + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => null + ])); + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'text' => Operator::concat('Test') + ])); + $this->assertEquals('Test', $updated->getAttribute('text')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorReplaceComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_replace_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false); + + // Success case - single replacement + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => 'Hello World' + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'text' => Operator::replace('World', 'Universe') + ])); + + $this->assertEquals('Hello Universe', $updated->getAttribute('text')); + + // Success case - multiple occurrences + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => 'test test test' + ])); + + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'text' => Operator::replace('test', 'demo') + ])); + + $this->assertEquals('demo demo demo', $updated->getAttribute('text')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayAppendComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_append_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'tags' => ['initial'] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'tags' => Operator::arrayAppend(['new', 'items']) + ])); + + $this->assertEquals(['initial', 'new', 'items'], $updated->getAttribute('tags')); + + // Edge case: empty array + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'tags' => [] + ])); + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'tags' => Operator::arrayAppend(['first']) + ])); + $this->assertEquals(['first'], $updated->getAttribute('tags')); + + // Edge case: null array + $doc3 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'tags' => null + ])); + $updated = $database->updateDocument($collectionId, $doc3->getId(), new Document([ + 'tags' => Operator::arrayAppend(['test']) + ])); + $this->assertEquals(['test'], $updated->getAttribute('tags')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayPrependComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_prepend_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['existing'] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayPrepend(['first', 'second']) + ])); + + $this->assertEquals(['first', 'second', 'existing'], $updated->getAttribute('items')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayInsertComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_insert_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + + // Success case - middle insertion + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [1, 2, 4] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayInsert(2, 3) + ])); + + $this->assertEquals([1, 2, 3, 4], $updated->getAttribute('numbers')); + + // Success case - beginning insertion + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayInsert(0, 0) + ])); + + $this->assertEquals([0, 1, 2, 3, 4], $updated->getAttribute('numbers')); + + // Success case - end insertion + $numbers = $updated->getAttribute('numbers'); + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayInsert(count($numbers), 5) + ])); + + $this->assertEquals([0, 1, 2, 3, 4, 5], $updated->getAttribute('numbers')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayRemoveComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_remove_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Success case - single occurrence + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c'] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayRemove('b') + ])); + + $this->assertEquals(['a', 'c'], $updated->getAttribute('items')); + + // Success case - multiple occurrences + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['x', 'y', 'x', 'z', 'x'] + ])); + + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'items' => Operator::arrayRemove('x') + ])); + + $this->assertEquals(['y', 'z'], $updated->getAttribute('items')); + + // Success case - non-existent value + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayRemove('nonexistent') + ])); + + $this->assertEquals(['a', 'c'], $updated->getAttribute('items')); // Should remain unchanged + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayUniqueComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_unique_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Success case - with duplicates + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'a', 'c', 'b', 'a'] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayUnique() + ])); + + $result = $updated->getAttribute('items'); + sort($result); // Sort for consistent comparison + $this->assertEquals(['a', 'b', 'c'], $result); + + // Success case - no duplicates + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['x', 'y', 'z'] + ])); + + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'items' => Operator::arrayUnique() + ])); + + $this->assertEquals(['x', 'y', 'z'], $updated->getAttribute('items')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayIntersectComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_intersect_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c', 'd'] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayIntersect(['b', 'c', 'e']) + ])); + + $result = $updated->getAttribute('items'); + sort($result); + $this->assertEquals(['b', 'c'], $result); + + // Success case - no intersection + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayIntersect(['x', 'y', 'z']) + ])); + + $this->assertEquals([], $updated->getAttribute('items')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayDiffComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_diff_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c', 'd'] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayDiff(['b', 'd']) + ])); + + $result = $updated->getAttribute('items'); + sort($result); + $this->assertEquals(['a', 'c'], $result); + + // Success case - empty diff array + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayDiff([]) + ])); + + $result = $updated->getAttribute('items'); + sort($result); + $this->assertEquals(['a', 'c'], $result); // Should remain unchanged + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayFilterComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_filter_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createAttribute($collectionId, 'mixed', Database::VAR_STRING, 50, false, null, true, true); + + // Success case - equals condition + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [1, 2, 3, 2, 4], + 'mixed' => ['a', 'b', null, 'c', null] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayFilter('equals', 2) + ])); + + $this->assertEquals([2, 2], $updated->getAttribute('numbers')); + + // Success case - notNull condition + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'mixed' => Operator::arrayFilter('notNull') + ])); + + $this->assertEquals(['a', 'b', 'c'], $updated->getAttribute('mixed')); + + // Success case - greaterThan condition (reset array first) + $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => [1, 2, 3, 2, 4] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayFilter('greaterThan', 2) + ])); + + $this->assertEquals([3, 4], $updated->getAttribute('numbers')); + + // Success case - lessThan condition (reset array first) + $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => [1, 2, 3, 2, 4] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayFilter('lessThan', 3) + ])); + + $this->assertEquals([1, 2, 2], $updated->getAttribute('numbers')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorArrayFilterNumericComparisons(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_filter_numeric_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'integers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createAttribute($collectionId, 'floats', Database::VAR_FLOAT, 0, false, null, true, true); + + // Create document with various numeric values + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'integers' => [1, 5, 10, 15, 20, 25], + 'floats' => [1.5, 5.5, 10.5, 15.5, 20.5, 25.5] + ])); + + // Test greaterThan with integers + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'integers' => Operator::arrayFilter('greaterThan', 10) + ])); + $this->assertEquals([15, 20, 25], $updated->getAttribute('integers')); + + // Reset and test lessThan with integers + $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'integers' => [1, 5, 10, 15, 20, 25] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'integers' => Operator::arrayFilter('lessThan', 15) + ])); + $this->assertEquals([1, 5, 10], $updated->getAttribute('integers')); + + // Test greaterThan with floats + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'floats' => Operator::arrayFilter('greaterThan', 10.5) + ])); + $this->assertEquals([15.5, 20.5, 25.5], $updated->getAttribute('floats')); + + // Reset and test lessThan with floats + $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'floats' => [1.5, 5.5, 10.5, 15.5, 20.5, 25.5] + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'floats' => Operator::arrayFilter('lessThan', 15.5) + ])); + $this->assertEquals([1.5, 5.5, 10.5], $updated->getAttribute('floats')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorToggleComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_toggle_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false); + + // Success case - true to false + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'active' => true + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'active' => Operator::toggle() + ])); + + $this->assertEquals(false, $updated->getAttribute('active')); + + // Success case - false to true + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'active' => Operator::toggle() + ])); + + $this->assertEquals(true, $updated->getAttribute('active')); + + // Success case - null to true + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'active' => null + ])); + + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'active' => Operator::toggle() + ])); + + $this->assertEquals(true, $updated->getAttribute('active')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorDateAddDaysComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_date_add_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'date', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + + // Success case - positive days + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'date' => '2023-01-01 00:00:00' + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'date' => Operator::dateAddDays(5) + ])); + + $this->assertEquals('2023-01-06T00:00:00.000+00:00', $updated->getAttribute('date')); + + // Success case - negative days (subtracting) + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'date' => Operator::dateAddDays(-3) + ])); + + $this->assertEquals('2023-01-03T00:00:00.000+00:00', $updated->getAttribute('date')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorDateSubDaysComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_date_sub_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'date', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'date' => '2023-01-10 00:00:00' + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'date' => Operator::dateSubDays(3) + ])); + + $this->assertEquals('2023-01-07T00:00:00.000+00:00', $updated->getAttribute('date')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorDateSetNowComprehensive(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'operator_date_now_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'timestamp', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + + // Success case + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'timestamp' => '2020-01-01 00:00:00' + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'timestamp' => Operator::dateSetNow() + ])); + + $result = $updated->getAttribute('timestamp'); + $this->assertNotEmpty($result); + + // Verify it's a recent timestamp (within last minute) + $now = new \DateTime(); + $resultDate = new \DateTime($result); + $diff = $now->getTimestamp() - $resultDate->getTimestamp(); + $this->assertLessThan(60, $diff); // Should be within 60 seconds + + $database->deleteCollection($collectionId); + } + + + public function testMixedOperators(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'mixed_operators_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 255, false); + $database->createAttribute($collectionId, 'active', Database::VAR_BOOLEAN, 0, false); + + // Test multiple operators in one update + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 5, + 'score' => 10.0, + 'tags' => ['initial'], + 'name' => 'Test', + 'active' => false + ])); + + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'count' => Operator::increment(3), + 'score' => Operator::multiply(1.5), + 'tags' => Operator::arrayAppend(['new', 'item']), + 'name' => Operator::concat(' Document'), + 'active' => Operator::toggle() + ])); + + $this->assertEquals(8, $updated->getAttribute('count')); + $this->assertEquals(15.0, $updated->getAttribute('score')); + $this->assertEquals(['initial', 'new', 'item'], $updated->getAttribute('tags')); + $this->assertEquals('Test Document', $updated->getAttribute('name')); + $this->assertEquals(true, $updated->getAttribute('active')); + + $database->deleteCollection($collectionId); + } + + public function testOperatorsBatch(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'batch_operators_test'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false); + $database->createAttribute($collectionId, 'category', Database::VAR_STRING, 50, false); + + // Create multiple documents + $docs = []; + for ($i = 1; $i <= 3; $i++) { + $docs[] = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => $i * 5, + 'category' => 'test' + ])); + } + + // Test updateDocuments with operators + $updateCount = $database->updateDocuments($collectionId, new Document([ + 'count' => Operator::increment(10) + ]), [ + Query::equal('category', ['test']) + ]); + + $this->assertEquals(3, $updateCount); + + // Fetch the updated documents to verify the operator worked + $updated = $database->find($collectionId, [ + Query::equal('category', ['test']), + Query::orderAsc('count') + ]); + $this->assertCount(3, $updated); + $this->assertEquals(15, $updated[0]->getAttribute('count')); // 5 + 10 + $this->assertEquals(20, $updated[1]->getAttribute('count')); // 10 + 10 + $this->assertEquals(25, $updated[2]->getAttribute('count')); // 15 + 10 + + $database->deleteCollection($collectionId); + } + + /** + * Test ARRAY_INSERT at beginning of array + * + * This test verifies that inserting at index 0 actually adds the element + */ + public function testArrayInsertAtBeginning(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'test_array_insert_beginning'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['second', 'third', 'fourth'] + ])); + + $this->assertEquals(['second', 'third', 'fourth'], $doc->getAttribute('items')); + + // Attempt to insert at index 0 + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayInsert(0, 'first') + ])); + + // Refetch to get the actual database value + $refetched = $database->getDocument($collectionId, $doc->getId()); + + // Should insert 'first' at index 0, shifting existing elements + $this->assertEquals( + ['first', 'second', 'third', 'fourth'], + $refetched->getAttribute('items'), + 'ARRAY_INSERT should insert element at index 0' + ); + + $database->deleteCollection($collectionId); + } + + /** + * Test ARRAY_INSERT at middle of array + * + * This test verifies that inserting at index 2 in a 5-element array works + */ + public function testArrayInsertAtMiddle(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'test_array_insert_middle'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_INTEGER, 0, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => [1, 2, 4, 5, 6] + ])); + + $this->assertEquals([1, 2, 4, 5, 6], $doc->getAttribute('items')); + + // Attempt to insert at index 2 (middle position) + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayInsert(2, 3) + ])); + + // Refetch to get the actual database value + $refetched = $database->getDocument($collectionId, $doc->getId()); + + // Should insert 3 at index 2, shifting remaining elements + $this->assertEquals( + [1, 2, 3, 4, 5, 6], + $refetched->getAttribute('items'), + 'ARRAY_INSERT should insert element at index 2' + ); + + $database->deleteCollection($collectionId); + } + + /** + * Test ARRAY_INSERT at end of array + * + * This test verifies that inserting at the last index (end of array) works + */ + public function testArrayInsertAtEnd(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'test_array_insert_end'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['apple', 'banana', 'cherry'] + ])); + + $this->assertEquals(['apple', 'banana', 'cherry'], $doc->getAttribute('items')); + + // Attempt to insert at end (index = length) + $items = $doc->getAttribute('items'); + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'items' => Operator::arrayInsert(count($items), 'date') + ])); + + // Refetch to get the actual database value + $refetched = $database->getDocument($collectionId, $doc->getId()); + + // Should insert 'date' at end of array + $this->assertEquals( + ['apple', 'banana', 'cherry', 'date'], + $refetched->getAttribute('items'), + 'ARRAY_INSERT should insert element at end of array' + ); + + $database->deleteCollection($collectionId); + } + + /** + * Test ARRAY_INSERT with multiple operations + * + * This test verifies that multiple sequential insert operations work correctly + */ + public function testArrayInsertMultipleOperations(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionId = 'test_array_insert_multiple'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [1, 3, 5] + ])); + + $this->assertEquals([1, 3, 5], $doc->getAttribute('numbers')); + + // First insert: add 2 at index 1 + $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayInsert(1, 2) + ])); + + // Refetch to get the actual database value + $refetched = $database->getDocument($collectionId, $doc->getId()); + + // Should insert 2 at index 1 + $this->assertEquals( + [1, 2, 3, 5], + $refetched->getAttribute('numbers'), + 'First ARRAY_INSERT should work' + ); + + // Second insert: add 4 at index 3 + $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayInsert(3, 4) + ])); + + // Refetch to get the actual database value + $refetched = $database->getDocument($collectionId, $doc->getId()); + + // Should insert 4 at index 3 + $this->assertEquals( + [1, 2, 3, 4, 5], + $refetched->getAttribute('numbers'), + 'Second ARRAY_INSERT should work' + ); + + // Third insert: add 0 at beginning + $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'numbers' => Operator::arrayInsert(0, 0) + ])); + + // Refetch to get the actual database value + $refetched = $database->getDocument($collectionId, $doc->getId()); + + // Should insert 0 at index 0 + $this->assertEquals( + [0, 1, 2, 3, 4, 5], + $refetched->getAttribute('numbers'), + 'Third ARRAY_INSERT should work' + ); + + $database->deleteCollection($collectionId); + } + + /** + * Bug #6: Post-Operator Validation Missing + * Test that INCREMENT operator can exceed maximum value constraint + * + * The database validates document structure BEFORE operators are applied (line 4912 in Database.php), + * but not AFTER. This test creates a document with an integer field that has a max constraint, + * then uses INCREMENT to push the value beyond that maximum. The operation should fail with a + * validation error, but currently succeeds because post-operator validation is missing. + */ + public function testOperatorIncrementExceedsMaxValue(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_increment_max_violation'; + $database->createCollection($collectionId); + + // Create an integer attribute with a maximum value of 100 + // Using size=4 (signed int) with max constraint through Range validator + $database->createAttribute($collectionId, 'score', Database::VAR_INTEGER, 4, false, 0, false, false); + + // Get the collection to verify attribute was created + $collection = $database->getCollection($collectionId); + $attributes = $collection->getAttribute('attributes', []); + $scoreAttr = null; + foreach ($attributes as $attr) { + if ($attr['$id'] === 'score') { + $scoreAttr = $attr; + break; + } + } + + // Create a document with score at 95 (within valid range) + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'score' => 95 + ])); + + $this->assertEquals(95, $doc->getAttribute('score')); + + // Test case 1: Small increment that stays within MAX_INT should work + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'score' => Operator::increment(5) + ])); + // Refetch to get the actual computed value + $updated = $database->getDocument($collectionId, $doc->getId()); + $this->assertEquals(100, $updated->getAttribute('score')); + + // Test case 2: Increment that would exceed Database::MAX_INT (2147483647) + // This is the bug - the operator will create a value > MAX_INT which should be rejected + // but post-operator validation is missing + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'score' => Database::MAX_INT - 10 // Start near the maximum + ])); + + $this->assertEquals(Database::MAX_INT - 10, $doc2->getAttribute('score')); + + // BUG EXPOSED: This increment will push the value beyond Database::MAX_INT + // It should throw a StructureException for exceeding the integer range, + // but currently succeeds because validation happens before operator application + try { + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'score' => Operator::increment(20) // Will result in MAX_INT + 10 + ])); + + // Refetch to get the actual computed value from the database + $refetched = $database->getDocument($collectionId, $doc2->getId()); + $finalScore = $refetched->getAttribute('score'); + + // Document the bug: The value should not exceed MAX_INT + $this->assertLessThanOrEqual( + Database::MAX_INT, + $finalScore, + "BUG EXPOSED: INCREMENT pushed score to {$finalScore}, exceeding MAX_INT (" . Database::MAX_INT . "). Post-operator validation is missing!" + ); + } catch (StructureException $e) { + // This is the CORRECT behavior - validation should catch the constraint violation + $this->assertStringContainsString('overflow maximum value', $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + + /** + * Bug #6: Post-Operator Validation Missing + * Test that CONCAT operator can exceed maximum string length + * + * This test creates a string attribute with a maximum length constraint, + * then uses CONCAT to make the string longer than allowed. The operation should fail, + * but currently succeeds because validation only happens before operators are applied. + */ + public function testOperatorConcatExceedsMaxLength(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_concat_length_violation'; + $database->createCollection($collectionId); + + // Create a string attribute with max length of 20 characters + $database->createAttribute($collectionId, 'title', Database::VAR_STRING, 20, false, ''); + + // Create a document with a 15-character title (within limit) + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Hello World' // 11 characters + ])); + + $this->assertEquals('Hello World', $doc->getAttribute('title')); + $this->assertEquals(11, strlen($doc->getAttribute('title'))); + + // BUG EXPOSED: Concat a 15-character string to make total length 26 (exceeds max of 20) + // This should throw a StructureException for exceeding max length, + // but currently succeeds because validation only checks the input, not the result + try { + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'title' => Operator::concat(' - Extended Title') // Adding 18 chars = 29 total + ])); + + // Refetch to get the actual computed value from the database + $refetched = $database->getDocument($collectionId, $doc->getId()); + $finalTitle = $refetched->getAttribute('title'); + $finalLength = strlen($finalTitle); + + // Document the bug: The resulting string should not exceed 20 characters + $this->assertLessThanOrEqual( + 20, + $finalLength, + "BUG EXPOSED: CONCAT created string of length {$finalLength} ('{$finalTitle}'), exceeding max length of 20. Post-operator validation is missing!" + ); + } catch (StructureException $e) { + // This is the CORRECT behavior - validation should catch the length violation + $this->assertStringContainsString('exceed maximum length', $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + + /** + * Bug #6: Post-Operator Validation Missing + * Test that MULTIPLY operator can create values outside allowed range + * + * This test shows that multiplying a float can exceed the maximum allowed value + * for the field type, bypassing schema constraints. + */ + public function testOperatorMultiplyViolatesRange(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_multiply_range_violation'; + $database->createCollection($collectionId); + + // Create a signed integer attribute (max value = Database::MAX_INT = 2147483647) + $database->createAttribute($collectionId, 'quantity', Database::VAR_INTEGER, 4, false, 1, false, false); + + // Create a document with quantity that when multiplied will exceed MAX_INT + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'quantity' => 1000000000 // 1 billion + ])); + + $this->assertEquals(1000000000, $doc->getAttribute('quantity')); + + // BUG EXPOSED: Multiply by 10 to get 10 billion, which exceeds MAX_INT (2.147 billion) + // This should throw a StructureException for exceeding the integer range, + // but currently may succeed or cause overflow because validation is missing + try { + $updated = $database->updateDocument($collectionId, $doc->getId(), new Document([ + 'quantity' => Operator::multiply(10) // 1,000,000,000 * 10 = 10,000,000,000 > MAX_INT + ])); + + // Refetch to get the actual computed value from the database + $refetched = $database->getDocument($collectionId, $doc->getId()); + $finalQuantity = $refetched->getAttribute('quantity'); + + // Document the bug: The value should not exceed MAX_INT + $this->assertLessThanOrEqual( + Database::MAX_INT, + $finalQuantity, + "BUG EXPOSED: MULTIPLY created value {$finalQuantity}, exceeding MAX_INT (" . Database::MAX_INT . "). Post-operator validation is missing!" + ); + + // Also verify the value didn't overflow into negative (integer overflow behavior) + $this->assertGreaterThan( + 0, + $finalQuantity, + "BUG EXPOSED: MULTIPLY caused integer overflow to {$finalQuantity}. Post-operator validation should prevent this!" + ); + } catch (StructureException $e) { + // This is the CORRECT behavior - validation should catch the range violation + $this->assertStringContainsString('overflow maximum value', $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + + /** + * Test MULTIPLY operator with negative multipliers and max limit + * Tests: Negative multipliers should not trigger incorrect overflow checks + */ + public function testOperatorMultiplyWithNegativeMultiplier(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_multiply_negative'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + + // Test negative multiplier without max limit + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'negative_multiply', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 10.0 + ])); + + $updated1 = $database->updateDocument($collectionId, 'negative_multiply', new Document([ + 'value' => Operator::multiply(-2) + ])); + $this->assertEquals(-20.0, $updated1->getAttribute('value'), 'Multiply by negative should work correctly'); + + // Test negative multiplier WITH max limit - should not incorrectly cap + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'negative_with_max', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 10.0 + ])); + + $updated2 = $database->updateDocument($collectionId, 'negative_with_max', new Document([ + 'value' => Operator::multiply(-2, 100) // max=100, but result will be -20 + ])); + $this->assertEquals(-20.0, $updated2->getAttribute('value'), 'Negative multiplier with max should not trigger overflow check'); + + // Test positive value * negative multiplier - result is negative, should not cap + $doc3 = $database->createDocument($collectionId, new Document([ + '$id' => 'pos_times_neg', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 50.0 + ])); + + $updated3 = $database->updateDocument($collectionId, 'pos_times_neg', new Document([ + 'value' => Operator::multiply(-3, 100) // 50 * -3 = -150, should not be capped at 100 + ])); + $this->assertEquals(-150.0, $updated3->getAttribute('value'), 'Positive * negative should compute correctly (result is negative, no cap)'); + + // Test negative value * negative multiplier that SHOULD hit max cap + $doc4 = $database->createDocument($collectionId, new Document([ + '$id' => 'negative_overflow', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => -60.0 + ])); + + $updated4 = $database->updateDocument($collectionId, 'negative_overflow', new Document([ + 'value' => Operator::multiply(-3, 100) // -60 * -3 = 180, should be capped at 100 + ])); + $this->assertEquals(100.0, $updated4->getAttribute('value'), 'Negative * negative should cap at max when result would exceed it'); + + // Test zero multiplier with max + $doc5 = $database->createDocument($collectionId, new Document([ + '$id' => 'zero_multiply', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 50.0 + ])); + + $updated5 = $database->updateDocument($collectionId, 'zero_multiply', new Document([ + 'value' => Operator::multiply(0, 100) + ])); + $this->assertEquals(0.0, $updated5->getAttribute('value'), 'Multiply by zero should result in zero'); + + $database->deleteCollection($collectionId); + } + + /** + * Test DIVIDE operator with negative divisors and min limit + * Tests: Negative divisors should not trigger incorrect underflow checks + */ + public function testOperatorDivideWithNegativeDivisor(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_divide_negative'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false); + + // Test negative divisor without min limit + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'negative_divide', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 20.0 + ])); + + $updated1 = $database->updateDocument($collectionId, 'negative_divide', new Document([ + 'value' => Operator::divide(-2) + ])); + $this->assertEquals(-10.0, $updated1->getAttribute('value'), 'Divide by negative should work correctly'); + + // Test negative divisor WITH min limit - should not incorrectly cap + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'negative_with_min', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 20.0 + ])); + + $updated2 = $database->updateDocument($collectionId, 'negative_with_min', new Document([ + 'value' => Operator::divide(-2, -50) // min=-50, result will be -10 + ])); + $this->assertEquals(-10.0, $updated2->getAttribute('value'), 'Negative divisor with min should not trigger underflow check'); + + // Test positive value / negative divisor - result is negative, should not cap at min + $doc3 = $database->createDocument($collectionId, new Document([ + '$id' => 'pos_div_neg', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 100.0 + ])); + + $updated3 = $database->updateDocument($collectionId, 'pos_div_neg', new Document([ + 'value' => Operator::divide(-4, -10) // 100 / -4 = -25, which is below min -10, so floor at -10 + ])); + $this->assertEquals(-10.0, $updated3->getAttribute('value'), 'Positive / negative should floor at min when result would be below it'); + + // Test negative value / negative divisor that would go below min + $doc4 = $database->createDocument($collectionId, new Document([ + '$id' => 'negative_underflow', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 40.0 + ])); + + $updated4 = $database->updateDocument($collectionId, 'negative_underflow', new Document([ + 'value' => Operator::divide(-2, -10) // 40 / -2 = -20, which is below min -10, so floor at -10 + ])); + $this->assertEquals(-10.0, $updated4->getAttribute('value'), 'Positive / negative should floor at min when result would be below it'); + + $database->deleteCollection($collectionId); + } + + /** + * Bug #6: Post-Operator Validation Missing + * Test that ARRAY_APPEND can add items that violate array item constraints + * + * This test creates an integer array attribute and uses ARRAY_APPEND to add a string, + * which should fail type validation but currently succeeds in some cases. + */ + public function testOperatorArrayAppendViolatesItemConstraints(): void + { + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_item_type_violation'; + $database->createCollection($collectionId); + + // Create an array attribute for integers with max value constraint + // Each item should be an integer within the valid range + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 4, false, null, true, true); + + // Create a document with valid integer array + $doc = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [10, 20, 30] + ])); + + $this->assertEquals([10, 20, 30], $doc->getAttribute('numbers')); + + // Test case 1: Append integers that exceed MAX_INT + // BUG EXPOSED: These values exceed the constraint but validation is not applied post-operator + try { + // Create a fresh document for this test + $doc2 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [100, 200] + ])); + + // Try to append values that would exceed MAX_INT + $hugeValue = Database::MAX_INT + 1000; // Exceeds integer maximum + + $updated = $database->updateDocument($collectionId, $doc2->getId(), new Document([ + 'numbers' => Operator::arrayAppend([$hugeValue]) + ])); + + // Refetch to get the actual computed value from the database + $refetched = $database->getDocument($collectionId, $doc2->getId()); + $finalNumbers = $refetched->getAttribute('numbers'); + $lastNumber = end($finalNumbers); + + // Document the bug: Array items should not exceed MAX_INT + $this->assertLessThanOrEqual( + Database::MAX_INT, + $lastNumber, + "BUG EXPOSED: ARRAY_APPEND added value {$lastNumber} exceeding MAX_INT (" . Database::MAX_INT . "). Post-operator validation is missing!" + ); + } catch (StructureException $e) { + // This is the CORRECT behavior - validation should catch the constraint violation + $this->assertStringContainsString('array items must be between', $e->getMessage()); + } catch (TypeException $e) { + // Also acceptable - type validation catches the issue + $this->assertStringContainsString('Invalid', $e->getMessage()); + } + + // Test case 2: Append multiple items where at least one violates constraints + try { + $doc3 = $database->createDocument($collectionId, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'numbers' => [1, 2, 3] + ])); + + // Append a mix of valid and invalid values + // The last value exceeds MAX_INT + $mixedValues = [40, 50, Database::MAX_INT + 100]; + + $updated = $database->updateDocument($collectionId, $doc3->getId(), new Document([ + 'numbers' => Operator::arrayAppend($mixedValues) + ])); + + // Refetch to get the actual computed value from the database + $refetched = $database->getDocument($collectionId, $doc3->getId()); + $finalNumbers = $refetched->getAttribute('numbers'); + + // Document the bug: ALL array items should be validated + foreach ($finalNumbers as $num) { + $this->assertLessThanOrEqual( + Database::MAX_INT, + $num, + "BUG EXPOSED: ARRAY_APPEND added invalid value {$num} exceeding MAX_INT (" . Database::MAX_INT . "). Post-operator validation is missing!" + ); + } + } catch (StructureException $e) { + // This is the CORRECT behavior + $this->assertTrue( + str_contains($e->getMessage(), 'invalid type') || + str_contains($e->getMessage(), 'array items must be between'), + 'Expected constraint violation message, got: ' . $e->getMessage() + ); + } catch (TypeException $e) { + // Also acceptable + $this->assertStringContainsString('Invalid', $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 1: Test operators with MAXIMUM and MINIMUM integer values + * Tests: Integer overflow/underflow prevention, boundary arithmetic + */ + public function testOperatorWithExtremeIntegerValues(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_extreme_integers'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'bigint_max', Database::VAR_INTEGER, 8, true); + $database->createAttribute($collectionId, 'bigint_min', Database::VAR_INTEGER, 8, true); + + $maxValue = PHP_INT_MAX - 1000; // Near max but with room + $minValue = PHP_INT_MIN + 1000; // Near min but with room + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'extreme_int_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'bigint_max' => $maxValue, + 'bigint_min' => $minValue + ])); + + // Test increment near max with limit + $updated = $database->updateDocument($collectionId, 'extreme_int_doc', new Document([ + 'bigint_max' => Operator::increment(2000, PHP_INT_MAX - 500) + ])); + // Should be capped at max + $this->assertLessThanOrEqual(PHP_INT_MAX - 500, $updated->getAttribute('bigint_max')); + $this->assertEquals(PHP_INT_MAX - 500, $updated->getAttribute('bigint_max')); + + // Test decrement near min with limit + $updated = $database->updateDocument($collectionId, 'extreme_int_doc', new Document([ + 'bigint_min' => Operator::decrement(2000, PHP_INT_MIN + 500) + ])); + // Should be capped at min + $this->assertGreaterThanOrEqual(PHP_INT_MIN + 500, $updated->getAttribute('bigint_min')); + $this->assertEquals(PHP_INT_MIN + 500, $updated->getAttribute('bigint_min')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 2: Test NEGATIVE exponents in power operator + * Tests: Fractional results, precision handling + */ + public function testOperatorPowerWithNegativeExponent(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_negative_power'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + + // Create document with value 8 + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'neg_power_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 8.0 + ])); + + // Test negative exponent: 8^(-2) = 1/64 = 0.015625 + $updated = $database->updateDocument($collectionId, 'neg_power_doc', new Document([ + 'value' => Operator::power(-2) + ])); + + $this->assertEqualsWithDelta(0.015625, $updated->getAttribute('value'), 0.000001); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 3: Test FRACTIONAL exponents in power operator + * Tests: Square roots, cube roots via fractional powers + */ + public function testOperatorPowerWithFractionalExponent(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_fractional_power'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + + // Create document with value 16 + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'frac_power_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 16.0 + ])); + + // Test fractional exponent: 16^(0.5) = sqrt(16) = 4 + $updated = $database->updateDocument($collectionId, 'frac_power_doc', new Document([ + 'value' => Operator::power(0.5) + ])); + + $this->assertEqualsWithDelta(4.0, $updated->getAttribute('value'), 0.000001); + + // Test cube root: 27^(1/3) = 3 + $database->updateDocument($collectionId, 'frac_power_doc', new Document([ + 'value' => 27.0 + ])); + + $updated = $database->updateDocument($collectionId, 'frac_power_doc', new Document([ + 'value' => Operator::power(1 / 3) + ])); + + $this->assertEqualsWithDelta(3.0, $updated->getAttribute('value'), 0.000001); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 4: Test EMPTY STRING operations + * Tests: Concatenation with empty strings, replacement edge cases + */ + public function testOperatorWithEmptyStrings(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_empty_strings'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, ''); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'empty_str_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => '' + ])); + + // Test concatenation to empty string + $updated = $database->updateDocument($collectionId, 'empty_str_doc', new Document([ + 'text' => Operator::concat('hello') + ])); + $this->assertEquals('hello', $updated->getAttribute('text')); + + // Test concatenation of empty string + $updated = $database->updateDocument($collectionId, 'empty_str_doc', new Document([ + 'text' => Operator::concat('') + ])); + $this->assertEquals('hello', $updated->getAttribute('text')); + + // Test replace with empty search string (should do nothing or replace all) + $database->updateDocument($collectionId, 'empty_str_doc', new Document([ + 'text' => 'test' + ])); + + $updated = $database->updateDocument($collectionId, 'empty_str_doc', new Document([ + 'text' => Operator::replace('', 'X') + ])); + // Empty search should not change the string + $this->assertEquals('test', $updated->getAttribute('text')); + + // Test replace with empty replace string (deletion) + $updated = $database->updateDocument($collectionId, 'empty_str_doc', new Document([ + 'text' => Operator::replace('t', '') + ])); + $this->assertEquals('es', $updated->getAttribute('text')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 5: Test UNICODE edge cases in string operations + * Tests: Multi-byte character handling, emoji operations + */ + public function testOperatorWithUnicodeCharacters(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_unicode'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 500, false, ''); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'unicode_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => '你好' + ])); + + // Test concatenation with emoji + $updated = $database->updateDocument($collectionId, 'unicode_doc', new Document([ + 'text' => Operator::concat('👋🌍') + ])); + $this->assertEquals('你好👋🌍', $updated->getAttribute('text')); + + // Test replace with Chinese characters + $updated = $database->updateDocument($collectionId, 'unicode_doc', new Document([ + 'text' => Operator::replace('你好', '再见') + ])); + $this->assertEquals('再见👋🌍', $updated->getAttribute('text')); + + // Test with combining characters (é = e + ´) + $database->updateDocument($collectionId, 'unicode_doc', new Document([ + 'text' => 'cafe\u{0301}' // café with combining acute accent + ])); + + $updated = $database->updateDocument($collectionId, 'unicode_doc', new Document([ + 'text' => Operator::concat(' ☕') + ])); + $this->assertStringContainsString('☕', $updated->getAttribute('text')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 6: Test array operations on EMPTY ARRAYS + * Tests: Behavior with zero-length arrays + */ + public function testOperatorArrayOperationsOnEmptyArrays(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_empty_arrays'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'empty_array_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => [] + ])); + + // Test append to empty array + $updated = $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => Operator::arrayAppend(['first']) + ])); + $this->assertEquals(['first'], $updated->getAttribute('items')); + + // Reset and test prepend to empty array + $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => [] + ])); + + $updated = $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => Operator::arrayPrepend(['prepended']) + ])); + $this->assertEquals(['prepended'], $updated->getAttribute('items')); + + // Test insert at index 0 of empty array + $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => [] + ])); + + $updated = $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => Operator::arrayInsert(0, 'zero') + ])); + $this->assertEquals(['zero'], $updated->getAttribute('items')); + + // Test unique on empty array + $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => [] + ])); + + $updated = $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => Operator::arrayUnique() + ])); + $this->assertEquals([], $updated->getAttribute('items')); + + // Test remove from empty array (should stay empty) + $updated = $database->updateDocument($collectionId, 'empty_array_doc', new Document([ + 'items' => Operator::arrayRemove('nonexistent') + ])); + $this->assertEquals([], $updated->getAttribute('items')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 7: Test array operations with NULL and special values + * Tests: How operators handle null, empty strings, and mixed types in arrays + */ + public function testOperatorArrayWithNullAndSpecialValues(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_special_values'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'mixed', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'special_values_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'mixed' => ['', 'text', '', 'text'] + ])); + + // Test unique with empty strings (should deduplicate) + $updated = $database->updateDocument($collectionId, 'special_values_doc', new Document([ + 'mixed' => Operator::arrayUnique() + ])); + $this->assertContains('', $updated->getAttribute('mixed')); + $this->assertContains('text', $updated->getAttribute('mixed')); + // Should have only 2 unique values: '' and 'text' + $this->assertCount(2, $updated->getAttribute('mixed')); + + // Test remove empty string + $database->updateDocument($collectionId, 'special_values_doc', new Document([ + 'mixed' => ['', 'a', '', 'b'] + ])); + + $updated = $database->updateDocument($collectionId, 'special_values_doc', new Document([ + 'mixed' => Operator::arrayRemove('') + ])); + $this->assertNotContains('', $updated->getAttribute('mixed')); + $this->assertEquals(['a', 'b'], $updated->getAttribute('mixed')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 8: Test MODULO with negative numbers + * Tests: Sign preservation, mathematical correctness + */ + public function testOperatorModuloWithNegativeNumbers(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_negative_modulo'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_INTEGER, 0, true); + + // Test -17 % 5 (different languages handle this differently) + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'neg_mod_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => -17 + ])); + + $updated = $database->updateDocument($collectionId, 'neg_mod_doc', new Document([ + 'value' => Operator::modulo(5) + ])); + + // In PHP/MySQL: -17 % 5 = -2 + $this->assertEquals(-2, $updated->getAttribute('value')); + + // Test positive % negative + $database->updateDocument($collectionId, 'neg_mod_doc', new Document([ + 'value' => 17 + ])); + + $updated = $database->updateDocument($collectionId, 'neg_mod_doc', new Document([ + 'value' => Operator::modulo(-5) + ])); + + // In PHP/MySQL: 17 % -5 = 2 + $this->assertEquals(2, $updated->getAttribute('value')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 9: Test FLOAT PRECISION issues + * Tests: Rounding errors, precision loss in arithmetic + */ + public function testOperatorFloatPrecisionLoss(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_float_precision'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'precision_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 0.1 + ])); + + // Test repeated additions that expose floating point errors + // 0.1 + 0.1 + 0.1 should be 0.3, but might be 0.30000000000000004 + $updated = $database->updateDocument($collectionId, 'precision_doc', new Document([ + 'value' => Operator::increment(0.1) + ])); + $updated = $database->updateDocument($collectionId, 'precision_doc', new Document([ + 'value' => Operator::increment(0.1) + ])); + + // Use delta for float comparison + $this->assertEqualsWithDelta(0.3, $updated->getAttribute('value'), 0.000001); + + // Test division that creates repeating decimal + $database->updateDocument($collectionId, 'precision_doc', new Document([ + 'value' => 10.0 + ])); + + $updated = $database->updateDocument($collectionId, 'precision_doc', new Document([ + 'value' => Operator::divide(3.0) + ])); + + // 10/3 = 3.333... + $this->assertEqualsWithDelta(3.333333, $updated->getAttribute('value'), 0.000001); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 10: Test VERY LONG string concatenation + * Tests: Performance with large strings, memory limits + */ + public function testOperatorWithVeryLongStrings(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_long_strings'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 70000, false, ''); + + // Create a long string (10k characters) + $longString = str_repeat('A', 10000); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'long_str_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => $longString + ])); + + // Concat another 10k + $updated = $database->updateDocument($collectionId, 'long_str_doc', new Document([ + 'text' => Operator::concat(str_repeat('B', 10000)) + ])); + + $result = $updated->getAttribute('text'); + $this->assertEquals(20000, strlen($result)); + $this->assertStringStartsWith('AAA', $result); + $this->assertStringEndsWith('BBB', $result); + + // Test replace on long string + $updated = $database->updateDocument($collectionId, 'long_str_doc', new Document([ + 'text' => Operator::replace('A', 'X') + ])); + + $result = $updated->getAttribute('text'); + $this->assertStringNotContainsString('A', $result); + $this->assertStringContainsString('X', $result); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 11: Test DATE operations at year boundaries + * Tests: Year rollover, leap year handling, edge timestamps + */ + public function testOperatorDateAtYearBoundaries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_date_boundaries'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'date', Database::VAR_DATETIME, 0, false, null, true, false, null, [], ['datetime']); + + // Test date at end of year + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'date_boundary_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'date' => '2023-12-31 23:59:59' + ])); + + // Add 1 day (should roll to next year) + $updated = $database->updateDocument($collectionId, 'date_boundary_doc', new Document([ + 'date' => Operator::dateAddDays(1) + ])); + + $resultDate = $updated->getAttribute('date'); + $this->assertStringStartsWith('2024-01-01', $resultDate); + + // Test leap year: Feb 28, 2024 + 1 day = Feb 29, 2024 (leap year) + $database->updateDocument($collectionId, 'date_boundary_doc', new Document([ + 'date' => '2024-02-28 12:00:00' + ])); + + $updated = $database->updateDocument($collectionId, 'date_boundary_doc', new Document([ + 'date' => Operator::dateAddDays(1) + ])); + + $resultDate = $updated->getAttribute('date'); + $this->assertStringStartsWith('2024-02-29', $resultDate); + + // Test non-leap year: Feb 28, 2023 + 1 day = Mar 1, 2023 + $database->updateDocument($collectionId, 'date_boundary_doc', new Document([ + 'date' => '2023-02-28 12:00:00' + ])); + + $updated = $database->updateDocument($collectionId, 'date_boundary_doc', new Document([ + 'date' => Operator::dateAddDays(1) + ])); + + $resultDate = $updated->getAttribute('date'); + $this->assertStringStartsWith('2023-03-01', $resultDate); + + // Test large day addition (cross multiple months) + $database->updateDocument($collectionId, 'date_boundary_doc', new Document([ + 'date' => '2023-01-01 00:00:00' + ])); + + $updated = $database->updateDocument($collectionId, 'date_boundary_doc', new Document([ + 'date' => Operator::dateAddDays(365) + ])); + + $resultDate = $updated->getAttribute('date'); + $this->assertStringStartsWith('2024-01-01', $resultDate); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 12: Test ARRAY INSERT at exact boundaries + * Tests: Insert at length, insert at length+1 (should fail) + */ + public function testOperatorArrayInsertAtExactBoundaries(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_insert_boundaries'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'boundary_insert_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c'] + ])); + + // Test insert at exact length (index 3 of array with 3 elements = append) + $updated = $database->updateDocument($collectionId, 'boundary_insert_doc', new Document([ + 'items' => Operator::arrayInsert(3, 'd') + ])); + $this->assertEquals(['a', 'b', 'c', 'd'], $updated->getAttribute('items')); + + // Test insert beyond length (should throw exception) + try { + $database->updateDocument($collectionId, 'boundary_insert_doc', new Document([ + 'items' => Operator::arrayInsert(10, 'z') + ])); + $this->fail('Expected exception for out of bounds insert'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('out of bounds', $e->getMessage()); + } + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 13: Test SEQUENTIAL operator applications + * Tests: Multiple updates with operators in sequence + */ + public function testOperatorSequentialApplications(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_sequential_ops'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, ''); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'sequential_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 10, + 'text' => 'start' + ])); + + // Apply operators sequentially and verify cumulative effect + $updated = $database->updateDocument($collectionId, 'sequential_doc', new Document([ + 'counter' => Operator::increment(5) + ])); + $this->assertEquals(15, $updated->getAttribute('counter')); + + $updated = $database->updateDocument($collectionId, 'sequential_doc', new Document([ + 'counter' => Operator::multiply(2) + ])); + $this->assertEquals(30, $updated->getAttribute('counter')); + + $updated = $database->updateDocument($collectionId, 'sequential_doc', new Document([ + 'counter' => Operator::decrement(10) + ])); + $this->assertEquals(20, $updated->getAttribute('counter')); + + $updated = $database->updateDocument($collectionId, 'sequential_doc', new Document([ + 'counter' => Operator::divide(2) + ])); + $this->assertEquals(10, $updated->getAttribute('counter')); + + // Sequential string operations + $updated = $database->updateDocument($collectionId, 'sequential_doc', new Document([ + 'text' => Operator::concat('-middle') + ])); + $this->assertEquals('start-middle', $updated->getAttribute('text')); + + $updated = $database->updateDocument($collectionId, 'sequential_doc', new Document([ + 'text' => Operator::concat('-end') + ])); + $this->assertEquals('start-middle-end', $updated->getAttribute('text')); + + $updated = $database->updateDocument($collectionId, 'sequential_doc', new Document([ + 'text' => Operator::replace('-', '_') + ])); + $this->assertEquals('start_middle_end', $updated->getAttribute('text')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 14: Test operators with ZERO values + * Tests: Zero in arithmetic, empty behavior + */ + public function testOperatorWithZeroValues(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_zero_values'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'zero_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 0.0 + ])); + + // Increment from zero + $updated = $database->updateDocument($collectionId, 'zero_doc', new Document([ + 'value' => Operator::increment(5) + ])); + $this->assertEquals(5.0, $updated->getAttribute('value')); + + // Multiply by zero (should become zero) + $updated = $database->updateDocument($collectionId, 'zero_doc', new Document([ + 'value' => Operator::multiply(0) + ])); + $this->assertEquals(0.0, $updated->getAttribute('value')); + + // Power with zero base: 0^5 = 0 + $updated = $database->updateDocument($collectionId, 'zero_doc', new Document([ + 'value' => Operator::power(5) + ])); + $this->assertEquals(0.0, $updated->getAttribute('value')); + + // Increment and test power with zero exponent: n^0 = 1 + $database->updateDocument($collectionId, 'zero_doc', new Document([ + 'value' => 99.0 + ])); + + $updated = $database->updateDocument($collectionId, 'zero_doc', new Document([ + 'value' => Operator::power(0) + ])); + $this->assertEquals(1.0, $updated->getAttribute('value')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 15: Test ARRAY INTERSECT and DIFF with empty result sets + * Tests: What happens when operations produce empty arrays + */ + public function testOperatorArrayIntersectAndDiffWithEmptyResults(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_empty_results'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'empty_result_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c'] + ])); + + // Intersect with no common elements (result should be empty array) + $updated = $database->updateDocument($collectionId, 'empty_result_doc', new Document([ + 'items' => Operator::arrayIntersect(['x', 'y', 'z']) + ])); + $this->assertEquals([], $updated->getAttribute('items')); + + // Reset and test diff that removes all elements + $database->updateDocument($collectionId, 'empty_result_doc', new Document([ + 'items' => ['a', 'b', 'c'] + ])); + + $updated = $database->updateDocument($collectionId, 'empty_result_doc', new Document([ + 'items' => Operator::arrayDiff(['a', 'b', 'c']) + ])); + $this->assertEquals([], $updated->getAttribute('items')); + + // Test intersect on empty array + $updated = $database->updateDocument($collectionId, 'empty_result_doc', new Document([ + 'items' => Operator::arrayIntersect(['x', 'y']) + ])); + $this->assertEquals([], $updated->getAttribute('items')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 16: Test REPLACE with patterns that appear multiple times + * Tests: Replace all occurrences, not just first + */ + public function testOperatorReplaceMultipleOccurrences(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_replace_multiple'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'text', Database::VAR_STRING, 255, false, ''); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'replace_multi_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'text' => 'the cat and the dog' + ])); + + // Replace all occurrences of 'the' + $updated = $database->updateDocument($collectionId, 'replace_multi_doc', new Document([ + 'text' => Operator::replace('the', 'a') + ])); + $this->assertEquals('a cat and a dog', $updated->getAttribute('text')); + + // Replace with overlapping patterns + $database->updateDocument($collectionId, 'replace_multi_doc', new Document([ + 'text' => 'aaa bbb aaa ccc aaa' + ])); + + $updated = $database->updateDocument($collectionId, 'replace_multi_doc', new Document([ + 'text' => Operator::replace('aaa', 'X') + ])); + $this->assertEquals('X bbb X ccc X', $updated->getAttribute('text')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 17: Test INCREMENT/DECREMENT with FLOAT values that have many decimal places + * Tests: Precision preservation in arithmetic + */ + public function testOperatorIncrementDecrementWithPreciseFloats(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_precise_floats'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'precise_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'value' => 3.141592653589793 + ])); + + // Increment by precise float + $updated = $database->updateDocument($collectionId, 'precise_doc', new Document([ + 'value' => Operator::increment(2.718281828459045) + ])); + + // π + e ≈ 5.859874482048838 + $this->assertEqualsWithDelta(5.859874482, $updated->getAttribute('value'), 0.000001); + + // Decrement by precise float + $updated = $database->updateDocument($collectionId, 'precise_doc', new Document([ + 'value' => Operator::decrement(1.414213562373095) + ])); + + // (π + e) - √2 ≈ 4.44566 + $this->assertEqualsWithDelta(4.44566, $updated->getAttribute('value'), 0.0001); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 18: Test ARRAY operations with single-element arrays + * Tests: Boundary between empty and multi-element + */ + public function testOperatorArrayWithSingleElement(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_single_element'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'single_elem_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['only'] + ])); + + // Remove the only element + $updated = $database->updateDocument($collectionId, 'single_elem_doc', new Document([ + 'items' => Operator::arrayRemove('only') + ])); + $this->assertEquals([], $updated->getAttribute('items')); + + // Reset and test unique on single element + $database->updateDocument($collectionId, 'single_elem_doc', new Document([ + 'items' => ['single'] + ])); + + $updated = $database->updateDocument($collectionId, 'single_elem_doc', new Document([ + 'items' => Operator::arrayUnique() + ])); + $this->assertEquals(['single'], $updated->getAttribute('items')); + + // Test intersect with single element (match) + $updated = $database->updateDocument($collectionId, 'single_elem_doc', new Document([ + 'items' => Operator::arrayIntersect(['single']) + ])); + $this->assertEquals(['single'], $updated->getAttribute('items')); + + // Test intersect with single element (no match) + $database->updateDocument($collectionId, 'single_elem_doc', new Document([ + 'items' => ['single'] + ])); + + $updated = $database->updateDocument($collectionId, 'single_elem_doc', new Document([ + 'items' => Operator::arrayIntersect(['other']) + ])); + $this->assertEquals([], $updated->getAttribute('items')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 19: Test TOGGLE on default boolean values + * Tests: Toggle from default state + */ + public function testOperatorToggleFromDefaultValue(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_toggle_default'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'flag', Database::VAR_BOOLEAN, 0, false, false); + + // Create doc without setting flag (should use default false) + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'toggle_default_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + // Verify default + $this->assertEquals(false, $doc->getAttribute('flag')); + + // Toggle from default false to true + $updated = $database->updateDocument($collectionId, 'toggle_default_doc', new Document([ + 'flag' => Operator::toggle() + ])); + $this->assertEquals(true, $updated->getAttribute('flag')); + + // Toggle back + $updated = $database->updateDocument($collectionId, 'toggle_default_doc', new Document([ + 'flag' => Operator::toggle() + ])); + $this->assertEquals(false, $updated->getAttribute('flag')); + + $database->deleteCollection($collectionId); + } + + /** + * Edge Case 20: Test operators with ATTRIBUTE that has max/min constraints + * Tests: Interaction between operator limits and attribute constraints + */ + public function testOperatorWithAttributeConstraints(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_attribute_constraints'; + $database->createCollection($collectionId); + // Integer with size 0 (32-bit INT) + $database->createAttribute($collectionId, 'small_int', Database::VAR_INTEGER, 0, true); + + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'constraint_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'small_int' => 100 + ])); + + // Test increment with max that's within bounds + $updated = $database->updateDocument($collectionId, 'constraint_doc', new Document([ + 'small_int' => Operator::increment(50, 120) + ])); + $this->assertEquals(120, $updated->getAttribute('small_int')); + + // Test multiply that would exceed without limit + $database->updateDocument($collectionId, 'constraint_doc', new Document([ + 'small_int' => 1000 + ])); + + $updated = $database->updateDocument($collectionId, 'constraint_doc', new Document([ + 'small_int' => Operator::multiply(1000, 5000) + ])); + $this->assertEquals(5000, $updated->getAttribute('small_int')); + + $database->deleteCollection($collectionId); + } + + public function testBulkUpdateWithOperatorsCallbackReceivesFreshData(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_bulk_callback'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + + // Create multiple test documents + for ($i = 1; $i <= 5; $i++) { + $database->createDocument($collectionId, new Document([ + '$id' => "doc_{$i}", + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => $i * 10, + 'score' => $i * 5.5, + 'tags' => ["initial_{$i}"] + ])); + } + + $callbackResults = []; + $count = $database->updateDocuments( + $collectionId, + new Document([ + 'count' => Operator::increment(7), + 'score' => Operator::multiply(2), + 'tags' => Operator::arrayAppend(['updated']) + ]), + [], + Database::INSERT_BATCH_SIZE, + function (Document $doc, Document $old) use (&$callbackResults) { + // Verify callback receives fresh computed values, not Operator objects + $this->assertIsInt($doc->getAttribute('count')); + $this->assertIsFloat($doc->getAttribute('score')); + $this->assertIsArray($doc->getAttribute('tags')); + + // Verify values are actually computed + $expectedCount = $old->getAttribute('count') + 7; + $expectedScore = $old->getAttribute('score') * 2; + $expectedTags = array_merge($old->getAttribute('tags'), ['updated']); + + $this->assertEquals($expectedCount, $doc->getAttribute('count')); + $this->assertEquals($expectedScore, $doc->getAttribute('score')); + $this->assertEquals($expectedTags, $doc->getAttribute('tags')); + + $callbackResults[] = $doc->getId(); + } + ); + + $this->assertEquals(5, $count); + $this->assertCount(5, $callbackResults); + $this->assertEquals(['doc_1', 'doc_2', 'doc_3', 'doc_4', 'doc_5'], $callbackResults); + + $database->deleteCollection($collectionId); + } + + public function testBulkUpsertWithOperatorsCallbackReceivesFreshData(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_upsert_callback'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Create existing documents + $database->createDocument($collectionId, new Document([ + '$id' => 'existing_1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 100, + 'value' => 50.0, + 'items' => ['item1'] + ])); + + $database->createDocument($collectionId, new Document([ + '$id' => 'existing_2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 200, + 'value' => 75.0, + 'items' => ['item2'] + ])); + + $callbackResults = []; + + // Upsert documents with operators (update existing, create new) + $documents = [ + new Document([ + '$id' => 'existing_1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => Operator::increment(50), + 'value' => Operator::divide(2), + 'items' => Operator::arrayAppend(['new_item']) + ]), + new Document([ + '$id' => 'existing_2', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => Operator::decrement(25), + 'value' => Operator::multiply(1.5), + 'items' => Operator::arrayPrepend(['prepended']) + ]), + new Document([ + '$id' => 'new_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 500, + 'value' => 100.0, + 'items' => ['new'] + ]) + ]; + + $count = $database->upsertDocuments( + $collectionId, + $documents, + Database::INSERT_BATCH_SIZE, + function (Document $doc, ?Document $old) use (&$callbackResults) { + // Verify callback receives fresh computed values, not Operator objects + $this->assertIsInt($doc->getAttribute('count')); + $this->assertIsFloat($doc->getAttribute('value')); + $this->assertIsArray($doc->getAttribute('items')); + + if ($doc->getId() === 'existing_1' && $old !== null) { + $this->assertEquals(150, $doc->getAttribute('count')); // 100 + 50 + $this->assertEquals(25.0, $doc->getAttribute('value')); // 50 / 2 + $this->assertEquals(['item1', 'new_item'], $doc->getAttribute('items')); + } elseif ($doc->getId() === 'existing_2' && $old !== null) { + $this->assertEquals(175, $doc->getAttribute('count')); // 200 - 25 + $this->assertEquals(112.5, $doc->getAttribute('value')); // 75 * 1.5 + $this->assertEquals(['prepended', 'item2'], $doc->getAttribute('items')); + } elseif ($doc->getId() === 'new_doc' && $old === null) { + $this->assertEquals(500, $doc->getAttribute('count')); + $this->assertEquals(100.0, $doc->getAttribute('value')); + $this->assertEquals(['new'], $doc->getAttribute('items')); + } + + $callbackResults[] = $doc->getId(); + } + ); + + $this->assertEquals(3, $count); + $this->assertCount(3, $callbackResults); + + $database->deleteCollection($collectionId); + } + + public function testSingleUpsertWithOperators(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection + $collectionId = 'test_single_upsert'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'count', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + + // Test upsert with operators on new document (insert) + $doc = $database->upsertDocument($collectionId, new Document([ + '$id' => 'test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => 100, + 'score' => 50.0, + 'tags' => ['tag1', 'tag2'] + ])); + + $this->assertEquals(100, $doc->getAttribute('count')); + $this->assertEquals(50.0, $doc->getAttribute('score')); + $this->assertEquals(['tag1', 'tag2'], $doc->getAttribute('tags')); + + // Test upsert with operators on existing document (update) + $updated = $database->upsertDocument($collectionId, new Document([ + '$id' => 'test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => Operator::increment(25), + 'score' => Operator::multiply(2), + 'tags' => Operator::arrayAppend(['tag3']) + ])); + + // Verify operators were applied correctly + $this->assertEquals(125, $updated->getAttribute('count')); // 100 + 25 + $this->assertEquals(100.0, $updated->getAttribute('score')); // 50 * 2 + $this->assertEquals(['tag1', 'tag2', 'tag3'], $updated->getAttribute('tags')); + + // Verify values are not Operator objects + $this->assertIsInt($updated->getAttribute('count')); + $this->assertIsFloat($updated->getAttribute('score')); + $this->assertIsArray($updated->getAttribute('tags')); + + // Test another upsert with different operators + $updated = $database->upsertDocument($collectionId, new Document([ + '$id' => 'test_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'count' => Operator::decrement(50), + 'score' => Operator::divide(4), + 'tags' => Operator::arrayPrepend(['tag0']) + ])); + + $this->assertEquals(75, $updated->getAttribute('count')); // 125 - 50 + $this->assertEquals(25.0, $updated->getAttribute('score')); // 100 / 4 + $this->assertEquals(['tag0', 'tag1', 'tag2', 'tag3'], $updated->getAttribute('tags')); + + $database->deleteCollection($collectionId); + } + + public function testUpsertOperatorsOnNewDocuments(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + // Create test collection with all attribute types needed for operators + $collectionId = 'test_upsert_new_ops'; + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'score', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'price', Database::VAR_FLOAT, 0, false, 0.0); + $database->createAttribute($collectionId, 'quantity', Database::VAR_INTEGER, 0, false, 0); + $database->createAttribute($collectionId, 'tags', Database::VAR_STRING, 50, false, null, true, true); + $database->createAttribute($collectionId, 'numbers', Database::VAR_INTEGER, 0, false, null, true, true); + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 100, false, ''); + + // Test 1: INCREMENT on new document (should use 0 as default) + $doc1 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_increment', + '$permissions' => [Permission::read(Role::any())], + 'counter' => Operator::increment(10), + ])); + $this->assertEquals(10, $doc1->getAttribute('counter'), 'INCREMENT on new doc: 0 + 10 = 10'); + + // Test 2: DECREMENT on new document (should use 0 as default) + $doc2 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_decrement', + '$permissions' => [Permission::read(Role::any())], + 'counter' => Operator::decrement(5), + ])); + $this->assertEquals(-5, $doc2->getAttribute('counter'), 'DECREMENT on new doc: 0 - 5 = -5'); + + // Test 3: MULTIPLY on new document (should use 0 as default) + $doc3 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_multiply', + '$permissions' => [Permission::read(Role::any())], + 'score' => Operator::multiply(5), + ])); + $this->assertEquals(0.0, $doc3->getAttribute('score'), 'MULTIPLY on new doc: 0 * 5 = 0'); + + // Test 4: DIVIDE on new document (should use 0 as default, but may handle division carefully) + // Note: 0 / n = 0, so this should work + $doc4 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_divide', + '$permissions' => [Permission::read(Role::any())], + 'score' => Operator::divide(2), + ])); + $this->assertEquals(0.0, $doc4->getAttribute('score'), 'DIVIDE on new doc: 0 / 2 = 0'); + + // Test 5: ARRAY_APPEND on new document (should use [] as default) + $doc5 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_array_append', + '$permissions' => [Permission::read(Role::any())], + 'tags' => Operator::arrayAppend(['tag1', 'tag2']), + ])); + $this->assertEquals(['tag1', 'tag2'], $doc5->getAttribute('tags'), 'ARRAY_APPEND on new doc: [] + [tag1, tag2]'); + + // Test 6: ARRAY_PREPEND on new document (should use [] as default) + $doc6 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_array_prepend', + '$permissions' => [Permission::read(Role::any())], + 'tags' => Operator::arrayPrepend(['first']), + ])); + $this->assertEquals(['first'], $doc6->getAttribute('tags'), 'ARRAY_PREPEND on new doc: [first] + []'); + + // Test 7: ARRAY_INSERT on new document (should use [] as default, insert at position 0) + $doc7 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_array_insert', + '$permissions' => [Permission::read(Role::any())], + 'numbers' => Operator::arrayInsert(0, 42), + ])); + $this->assertEquals([42], $doc7->getAttribute('numbers'), 'ARRAY_INSERT on new doc: insert 42 at position 0'); + + // Test 8: ARRAY_REMOVE on new document (should use [] as default, nothing to remove) + $doc8 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_array_remove', + '$permissions' => [Permission::read(Role::any())], + 'tags' => Operator::arrayRemove(['nonexistent']), + ])); + $this->assertEquals([], $doc8->getAttribute('tags'), 'ARRAY_REMOVE on new doc: [] - [nonexistent] = []'); + + // Test 9: ARRAY_UNIQUE on new document (should use [] as default) + $doc9 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_array_unique', + '$permissions' => [Permission::read(Role::any())], + 'tags' => Operator::arrayUnique(), + ])); + $this->assertEquals([], $doc9->getAttribute('tags'), 'ARRAY_UNIQUE on new doc: unique([]) = []'); + + // Test 10: CONCAT on new document (should use empty string as default) + $doc10 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_concat', + '$permissions' => [Permission::read(Role::any())], + 'name' => Operator::concat(' World'), + ])); + $this->assertEquals(' World', $doc10->getAttribute('name'), 'CONCAT on new doc: "" + " World" = " World"'); + + // Test 11: REPLACE on new document (should use empty string as default) + $doc11 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_replace', + '$permissions' => [Permission::read(Role::any())], + 'name' => Operator::replace('old', 'new'), + ])); + $this->assertEquals('', $doc11->getAttribute('name'), 'REPLACE on new doc: replace("old", "new") in "" = ""'); + + // Test 12: Multiple operators on same new document + $doc12 = $database->upsertDocument($collectionId, new Document([ + '$id' => 'doc_multi', + '$permissions' => [Permission::read(Role::any())], + 'counter' => Operator::increment(100), + 'score' => Operator::increment(50.5), + 'tags' => Operator::arrayAppend(['multi1', 'multi2']), + 'name' => Operator::concat('MultiTest'), + ])); + $this->assertEquals(100, $doc12->getAttribute('counter')); + $this->assertEquals(50.5, $doc12->getAttribute('score')); + $this->assertEquals(['multi1', 'multi2'], $doc12->getAttribute('tags')); + $this->assertEquals('MultiTest', $doc12->getAttribute('name')); + + // Cleanup + $database->deleteCollection($collectionId); + } + + /** + * Test that array operators return empty arrays instead of NULL + * Tests: ARRAY_UNIQUE, ARRAY_INTERSECT, and ARRAY_DIFF return [] not NULL + */ + public function testOperatorArrayEmptyResultsNotNull(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_array_not_null'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true); + + // Test ARRAY_UNIQUE on empty array returns [] not NULL + $doc1 = $database->createDocument($collectionId, new Document([ + '$id' => 'empty_unique', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => [] + ])); + + $updated1 = $database->updateDocument($collectionId, 'empty_unique', new Document([ + 'items' => Operator::arrayUnique() + ])); + $this->assertIsArray($updated1->getAttribute('items'), 'ARRAY_UNIQUE should return array not NULL'); + $this->assertEquals([], $updated1->getAttribute('items'), 'ARRAY_UNIQUE on empty array should return []'); + + // Test ARRAY_INTERSECT with no matches returns [] not NULL + $doc2 = $database->createDocument($collectionId, new Document([ + '$id' => 'no_intersect', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c'] + ])); + + $updated2 = $database->updateDocument($collectionId, 'no_intersect', new Document([ + 'items' => Operator::arrayIntersect(['x', 'y', 'z']) + ])); + $this->assertIsArray($updated2->getAttribute('items'), 'ARRAY_INTERSECT should return array not NULL'); + $this->assertEquals([], $updated2->getAttribute('items'), 'ARRAY_INTERSECT with no matches should return []'); + + // Test ARRAY_DIFF removing all elements returns [] not NULL + $doc3 = $database->createDocument($collectionId, new Document([ + '$id' => 'diff_all', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'items' => ['a', 'b', 'c'] + ])); + + $updated3 = $database->updateDocument($collectionId, 'diff_all', new Document([ + 'items' => Operator::arrayDiff(['a', 'b', 'c']) + ])); + $this->assertIsArray($updated3->getAttribute('items'), 'ARRAY_DIFF should return array not NULL'); + $this->assertEquals([], $updated3->getAttribute('items'), 'ARRAY_DIFF removing all elements should return []'); + + // Cleanup + $database->deleteCollection($collectionId); + } + + /** + * Test that updateDocuments with operators properly invalidates cache + * Tests: Cache should be purged after operator updates to prevent stale data + */ + public function testUpdateDocumentsWithOperatorsCacheInvalidation(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForOperators()) { + $this->expectNotToPerformAssertions(); + return; + } + + + $collectionId = 'test_operator_cache'; + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0); + + // Create a document + $doc = $database->createDocument($collectionId, new Document([ + '$id' => 'cache_test', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'counter' => 10 + ])); + + // First read to potentially cache + $fetched1 = $database->getDocument($collectionId, 'cache_test'); + $this->assertEquals(10, $fetched1->getAttribute('counter')); + + // Use updateDocuments with operator + $count = $database->updateDocuments( + $collectionId, + new Document([ + 'counter' => Operator::increment(5) + ]), + [Query::equal('$id', ['cache_test'])] + ); + + $this->assertEquals(1, $count); + + // Read again - should get fresh value, not cached old value + $fetched2 = $database->getDocument($collectionId, 'cache_test'); + $this->assertEquals(15, $fetched2->getAttribute('counter'), 'Cache should be invalidated after operator update'); + + // Do another operator update + $database->updateDocuments( + $collectionId, + new Document([ + 'counter' => Operator::multiply(2) + ]) + ); + + // Verify cache was invalidated again + $fetched3 = $database->getDocument($collectionId, 'cache_test'); + $this->assertEquals(30, $fetched3->getAttribute('counter'), 'Cache should be invalidated after second operator update'); + + $database->deleteCollection($collectionId); + } +} diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 15fb45b16..661182b3f 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -2802,4 +2802,99 @@ public function testCreateSpatialColumnWithExistingData(): void $database->deleteCollection($col); } } + + /** + * Test that spatial arrays are properly converted to WKT in updateDocument + * Tests: Spatial attributes should use ST_GeomFromText with WKT conversion, not JSON encoding + */ + public function testSpatialArrayWKTConversionInUpdateDocument(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'test_spatial_wkt_conversion'; + + try { + $database->createCollection($collectionName); + // Use required=true for spatial attributes to support spatial indexes (MariaDB requires this) + $database->createAttribute($collectionName, 'location', Database::VAR_POINT, 0, true); + $database->createAttribute($collectionName, 'route', Database::VAR_LINESTRING, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true); + $database->createAttribute($collectionName, 'area', Database::VAR_POLYGON, 0, $database->getAdapter()->getSupportForSpatialIndexNull() ? false : true); + $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 100, false); + + // Create indexes for spatial queries + $database->createIndex($collectionName, 'location_idx', Database::INDEX_SPATIAL, ['location']); + $database->createIndex($collectionName, 'route_idx', Database::INDEX_SPATIAL, ['route']); + $database->createIndex($collectionName, 'area_idx', Database::INDEX_SPATIAL, ['area']); + + // Create initial document with spatial arrays + $initialPoint = [10.0, 20.0]; + $initialLine = [[0.0, 0.0], [5.0, 5.0]]; + $initialPolygon = [[[0.0, 0.0], [0.0, 5.0], [5.0, 5.0], [5.0, 0.0], [0.0, 0.0]]]; + + $doc = $database->createDocument($collectionName, new Document([ + '$id' => 'spatial_doc', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'location' => $initialPoint, + 'route' => $initialLine, + 'area' => $initialPolygon, + 'name' => 'Original' + ])); + + // Verify initial values + $this->assertEquals($initialPoint, $doc->getAttribute('location')); + $this->assertEquals($initialLine, $doc->getAttribute('route')); + $this->assertEquals($initialPolygon, $doc->getAttribute('area')); + + // Update document with NEW spatial arrays (this triggers the WKT conversion path) + $newPoint = [30.0, 40.0]; + $newLine = [[10.0, 10.0], [20.0, 20.0], [30.0, 30.0]]; + $newPolygon = [[[10.0, 10.0], [10.0, 20.0], [20.0, 20.0], [20.0, 10.0], [10.0, 10.0]]]; + + $updated = $database->updateDocument($collectionName, 'spatial_doc', new Document([ + 'location' => $newPoint, + 'route' => $newLine, + 'area' => $newPolygon, + 'name' => 'Updated' + ])); + + // Verify updated spatial values are correctly stored and retrieved + $this->assertEquals($newPoint, $updated->getAttribute('location'), 'Point should be updated correctly via WKT conversion'); + $this->assertEquals($newLine, $updated->getAttribute('route'), 'LineString should be updated correctly via WKT conversion'); + $this->assertEquals($newPolygon, $updated->getAttribute('area'), 'Polygon should be updated correctly via WKT conversion'); + $this->assertEquals('Updated', $updated->getAttribute('name')); + + // Refetch from database to ensure data was persisted correctly + $refetched = $database->getDocument($collectionName, 'spatial_doc'); + $this->assertEquals($newPoint, $refetched->getAttribute('location'), 'Point should persist correctly after WKT conversion'); + $this->assertEquals($newLine, $refetched->getAttribute('route'), 'LineString should persist correctly after WKT conversion'); + $this->assertEquals($newPolygon, $refetched->getAttribute('area'), 'Polygon should persist correctly after WKT conversion'); + + // Test spatial queries work with updated data + $results = $database->find($collectionName, [ + Query::equal('location', [$newPoint]) + ]); + $this->assertCount(1, $results, 'Should find document by exact point match'); + $this->assertEquals('spatial_doc', $results[0]->getId()); + + // Test mixed update (spatial + non-spatial attributes) + $updated2 = $database->updateDocument($collectionName, 'spatial_doc', new Document([ + 'location' => [50.0, 60.0], + 'name' => 'Mixed Update' + ])); + $this->assertEquals([50.0, 60.0], $updated2->getAttribute('location')); + $this->assertEquals('Mixed Update', $updated2->getAttribute('name')); + // Other spatial attributes should remain unchanged + $this->assertEquals($newLine, $updated2->getAttribute('route')); + $this->assertEquals($newPolygon, $updated2->getAttribute('area')); + + } finally { + $database->deleteCollection($collectionName); + } + } } diff --git a/tests/unit/OperatorTest.php b/tests/unit/OperatorTest.php new file mode 100644 index 000000000..969a94acd --- /dev/null +++ b/tests/unit/OperatorTest.php @@ -0,0 +1,954 @@ +assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals('count', $operator->getAttribute()); + $this->assertEquals([1], $operator->getValues()); + $this->assertEquals(1, $operator->getValue()); + + // Test with different types + $operator = new Operator(Operator::TYPE_ARRAY_APPEND, 'tags', ['php', 'database']); + + $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operator->getMethod()); + $this->assertEquals('tags', $operator->getAttribute()); + $this->assertEquals(['php', 'database'], $operator->getValues()); + $this->assertEquals('php', $operator->getValue()); + } + + public function testHelperMethods(): void + { + // Test increment helper + $operator = Operator::increment(5); + $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); // Initially empty + $this->assertEquals([5], $operator->getValues()); + + // Test decrement helper + $operator = Operator::decrement(1); + $this->assertEquals(Operator::TYPE_DECREMENT, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); // Initially empty + $this->assertEquals([1], $operator->getValues()); + + // Test default increment value + $operator = Operator::increment(); + $this->assertEquals(1, $operator->getValue()); + + // Test string helpers + $operator = Operator::concat(' - Updated'); + $this->assertEquals(Operator::TYPE_CONCAT, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals([' - Updated'], $operator->getValues()); + + $operator = Operator::replace('old', 'new'); + $this->assertEquals(Operator::TYPE_REPLACE, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals(['old', 'new'], $operator->getValues()); + + // Test math helpers + $operator = Operator::multiply(2, 1000); + $this->assertEquals(Operator::TYPE_MULTIPLY, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals([2, 1000], $operator->getValues()); + + $operator = Operator::divide(2, 1); + $this->assertEquals(Operator::TYPE_DIVIDE, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals([2, 1], $operator->getValues()); + + // Test boolean helper + $operator = Operator::toggle(); + $this->assertEquals(Operator::TYPE_TOGGLE, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals([], $operator->getValues()); + + $operator = Operator::dateSetNow(); + $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals([], $operator->getValues()); + + // Test concat helper + $operator = Operator::concat(' - Updated'); + $this->assertEquals(Operator::TYPE_CONCAT, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals([' - Updated'], $operator->getValues()); + + // Test modulo and power operators + $operator = Operator::modulo(3); + $this->assertEquals(Operator::TYPE_MODULO, $operator->getMethod()); + $this->assertEquals([3], $operator->getValues()); + + $operator = Operator::power(2, 1000); + $this->assertEquals(Operator::TYPE_POWER, $operator->getMethod()); + $this->assertEquals([2, 1000], $operator->getValues()); + + // Test new array helper methods + $operator = Operator::arrayAppend(['new', 'values']); + $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals(['new', 'values'], $operator->getValues()); + + $operator = Operator::arrayPrepend(['first', 'second']); + $this->assertEquals(Operator::TYPE_ARRAY_PREPEND, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals(['first', 'second'], $operator->getValues()); + + $operator = Operator::arrayInsert(2, 'inserted'); + $this->assertEquals(Operator::TYPE_ARRAY_INSERT, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals([2, 'inserted'], $operator->getValues()); + + $operator = Operator::arrayRemove('unwanted'); + $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operator->getMethod()); + $this->assertEquals('', $operator->getAttribute()); + $this->assertEquals(['unwanted'], $operator->getValues()); + } + + public function testSetters(): void + { + $operator = new Operator(Operator::TYPE_INCREMENT, 'test', [1]); + + // Test setMethod + $operator->setMethod(Operator::TYPE_DECREMENT); + $this->assertEquals(Operator::TYPE_DECREMENT, $operator->getMethod()); + + // Test setAttribute + $operator->setAttribute('newAttribute'); + $this->assertEquals('newAttribute', $operator->getAttribute()); + + // Test setValues + $operator->setValues([10, 20]); + $this->assertEquals([10, 20], $operator->getValues()); + + // Test setValue + $operator->setValue(50); + $this->assertEquals([50], $operator->getValues()); + $this->assertEquals(50, $operator->getValue()); + } + + public function testTypeMethods(): void + { + // Test numeric operations + $incrementOp = Operator::increment(1); + $this->assertTrue($incrementOp->isNumericOperation()); + $this->assertFalse($incrementOp->isArrayOperation()); + + $decrementOp = Operator::decrement(1); + $this->assertTrue($decrementOp->isNumericOperation()); + $this->assertFalse($decrementOp->isArrayOperation()); + + // Test string operations + $concatOp = Operator::concat('suffix'); + $this->assertFalse($concatOp->isNumericOperation()); + $this->assertFalse($concatOp->isArrayOperation()); + $this->assertTrue($concatOp->isStringOperation()); + + $concatOp = Operator::concat('suffix'); // Deprecated + $this->assertFalse($concatOp->isNumericOperation()); + $this->assertFalse($concatOp->isArrayOperation()); + $this->assertTrue($concatOp->isStringOperation()); + + $replaceOp = Operator::replace('old', 'new'); + $this->assertFalse($replaceOp->isNumericOperation()); + $this->assertFalse($replaceOp->isArrayOperation()); + $this->assertTrue($replaceOp->isStringOperation()); + + // Test boolean operations + $toggleOp = Operator::toggle(); + $this->assertFalse($toggleOp->isNumericOperation()); + $this->assertFalse($toggleOp->isArrayOperation()); + $this->assertTrue($toggleOp->isBooleanOperation()); + + + // Test date operations + $dateSetNowOp = Operator::dateSetNow(); + $this->assertFalse($dateSetNowOp->isNumericOperation()); + $this->assertFalse($dateSetNowOp->isArrayOperation()); + $this->assertTrue($dateSetNowOp->isDateOperation()); + + // Test new array operations + $arrayAppendOp = Operator::arrayAppend(['tag']); + $this->assertFalse($arrayAppendOp->isNumericOperation()); + $this->assertTrue($arrayAppendOp->isArrayOperation()); + + $arrayPrependOp = Operator::arrayPrepend(['tag']); + $this->assertFalse($arrayPrependOp->isNumericOperation()); + $this->assertTrue($arrayPrependOp->isArrayOperation()); + + $arrayInsertOp = Operator::arrayInsert(0, 'item'); + $this->assertFalse($arrayInsertOp->isNumericOperation()); + $this->assertTrue($arrayInsertOp->isArrayOperation()); + + $arrayRemoveOp = Operator::arrayRemove('unwanted'); + $this->assertFalse($arrayRemoveOp->isNumericOperation()); + $this->assertTrue($arrayRemoveOp->isArrayOperation()); + } + + public function testIsMethod(): void + { + // Test valid methods + $this->assertTrue(Operator::isMethod(Operator::TYPE_INCREMENT)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_DECREMENT)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_MULTIPLY)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_DIVIDE)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_CONCAT)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_REPLACE)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_TOGGLE)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_CONCAT)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_DATE_SET_NOW)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_MODULO)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_POWER)); + + // Test new array methods + $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_APPEND)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_PREPEND)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_INSERT)); + $this->assertTrue(Operator::isMethod(Operator::TYPE_ARRAY_REMOVE)); + + // Test invalid methods + $this->assertFalse(Operator::isMethod('invalid')); + $this->assertFalse(Operator::isMethod('')); + $this->assertFalse(Operator::isMethod('append')); // Old method should be false + $this->assertFalse(Operator::isMethod('prepend')); // Old method should be false + $this->assertFalse(Operator::isMethod('insert')); // Old method should be false + } + + public function testIsOperator(): void + { + $operator = Operator::increment(1); + $this->assertTrue(Operator::isOperator($operator)); + + $this->assertFalse(Operator::isOperator('string')); + $this->assertFalse(Operator::isOperator(123)); + $this->assertFalse(Operator::isOperator([])); + $this->assertFalse(Operator::isOperator(null)); + } + + public function testExtractOperators(): void + { + $data = [ + 'name' => 'John', + 'count' => Operator::increment(5), + 'tags' => Operator::arrayAppend(['new']), + 'age' => 30 + ]; + + $result = Operator::extractOperators($data); + + $this->assertArrayHasKey('operators', $result); + $this->assertArrayHasKey('updates', $result); + + $operators = $result['operators']; + $updates = $result['updates']; + + // Check operators + $this->assertCount(2, $operators); + $this->assertInstanceOf(Operator::class, $operators['count']); + $this->assertInstanceOf(Operator::class, $operators['tags']); + + // Check that attributes are set from document keys + $this->assertEquals('count', $operators['count']->getAttribute()); + $this->assertEquals('tags', $operators['tags']->getAttribute()); + + // Check updates + $this->assertEquals(['name' => 'John', 'age' => 30], $updates); + } + + public function testSerialization(): void + { + $operator = Operator::increment(10); + $operator->setAttribute('score'); // Simulate setting attribute + + // Test toArray + $array = $operator->toArray(); + $expected = [ + 'method' => Operator::TYPE_INCREMENT, + 'attribute' => 'score', + 'values' => [10] + ]; + $this->assertEquals($expected, $array); + + // Test toString + $string = $operator->toString(); + $this->assertJson($string); + $decoded = json_decode($string, true); + $this->assertEquals($expected, $decoded); + } + + public function testParsing(): void + { + // Test parseOperator from array + $array = [ + 'method' => Operator::TYPE_INCREMENT, + 'attribute' => 'score', + 'values' => [5] + ]; + + $operator = Operator::parseOperator($array); + $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals('score', $operator->getAttribute()); + $this->assertEquals([5], $operator->getValues()); + + // Test parse from JSON string + $json = json_encode($array); + $this->assertIsString($json); + $operator = Operator::parse($json); + $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals('score', $operator->getAttribute()); + $this->assertEquals([5], $operator->getValues()); + } + + public function testParseOperators(): void + { + $json1 = json_encode(['method' => Operator::TYPE_INCREMENT, 'attribute' => 'count', 'values' => [1]]); + $json2 = json_encode(['method' => Operator::TYPE_ARRAY_APPEND, 'attribute' => 'tags', 'values' => ['new']]); + + $this->assertIsString($json1); + $this->assertIsString($json2); + + $operators = [$json1, $json2]; + + $parsed = Operator::parseOperators($operators); + $this->assertCount(2, $parsed); + $this->assertInstanceOf(Operator::class, $parsed[0]); + $this->assertInstanceOf(Operator::class, $parsed[1]); + $this->assertEquals(Operator::TYPE_INCREMENT, $parsed[0]->getMethod()); + $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $parsed[1]->getMethod()); + } + + public function testClone(): void + { + $operator1 = Operator::increment(5); + $operator2 = clone $operator1; + + $this->assertEquals($operator1->getMethod(), $operator2->getMethod()); + $this->assertEquals($operator1->getAttribute(), $operator2->getAttribute()); + $this->assertEquals($operator1->getValues(), $operator2->getValues()); + + // Ensure they are different objects + $operator2->setMethod(Operator::TYPE_DECREMENT); + $this->assertEquals(Operator::TYPE_INCREMENT, $operator1->getMethod()); + $this->assertEquals(Operator::TYPE_DECREMENT, $operator2->getMethod()); + } + + public function testGetValueWithDefault(): void + { + $operator = Operator::increment(5); + $this->assertEquals(5, $operator->getValue()); + $this->assertEquals(5, $operator->getValue('default')); + + $emptyOperator = new Operator(Operator::TYPE_INCREMENT, 'count', []); + $this->assertEquals('default', $emptyOperator->getValue('default')); + $this->assertNull($emptyOperator->getValue()); + } + + // Exception tests + + public function testParseInvalidJson(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Invalid operator'); + Operator::parse('invalid json'); + } + + public function testParseNonArray(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Invalid operator. Must be an array'); + Operator::parse('"string"'); + } + + public function testParseInvalidMethod(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Invalid operator method. Must be a string'); + $array = ['method' => 123, 'attribute' => 'test', 'values' => []]; + Operator::parseOperator($array); + } + + public function testParseUnsupportedMethod(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Invalid operator method: invalid'); + $array = ['method' => 'invalid', 'attribute' => 'test', 'values' => []]; + Operator::parseOperator($array); + } + + public function testParseInvalidAttribute(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Invalid operator attribute. Must be a string'); + $array = ['method' => Operator::TYPE_INCREMENT, 'attribute' => 123, 'values' => []]; + Operator::parseOperator($array); + } + + public function testParseInvalidValues(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Invalid operator values. Must be an array'); + $array = ['method' => Operator::TYPE_INCREMENT, 'attribute' => 'test', 'values' => 'not array']; + Operator::parseOperator($array); + } + + public function testToStringInvalidJson(): void + { + // Create an operator with values that can't be JSON encoded + $operator = new Operator(Operator::TYPE_INCREMENT, 'test', []); + $operator->setValues([fopen('php://memory', 'r')]); // Resource can't be JSON encoded + + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Invalid Json'); + $operator->toString(); + } + + // New functionality tests + + public function testIncrementWithMax(): void + { + // Test increment with max limit + $operator = Operator::increment(5, 10); + $this->assertEquals(Operator::TYPE_INCREMENT, $operator->getMethod()); + $this->assertEquals([5, 10], $operator->getValues()); + + // Test increment without max (should be same as original behavior) + $operator = Operator::increment(5); + $this->assertEquals([5], $operator->getValues()); + } + + public function testDecrementWithMin(): void + { + // Test decrement with min limit + $operator = Operator::decrement(3, 0); + $this->assertEquals(Operator::TYPE_DECREMENT, $operator->getMethod()); + $this->assertEquals([3, 0], $operator->getValues()); + + // Test decrement without min (should be same as original behavior) + $operator = Operator::decrement(3); + $this->assertEquals([3], $operator->getValues()); + } + + public function testArrayRemove(): void + { + $operator = Operator::arrayRemove('spam'); + $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operator->getMethod()); + $this->assertEquals(['spam'], $operator->getValues()); + $this->assertEquals('spam', $operator->getValue()); + } + + public function testExtractOperatorsWithNewMethods(): void + { + $data = [ + 'name' => 'John', + 'count' => Operator::increment(5, 100), // with max + 'score' => Operator::decrement(1, 0), // with min + 'tags' => Operator::arrayAppend(['new']), + 'items' => Operator::arrayPrepend(['first']), + 'list' => Operator::arrayInsert(2, 'value'), + 'blacklist' => Operator::arrayRemove('spam'), + 'title' => Operator::concat(' - Updated'), + 'content' => Operator::replace('old', 'new'), + 'views' => Operator::multiply(2, 1000), + 'rating' => Operator::divide(2, 1), + 'featured' => Operator::toggle(), + 'last_modified' => Operator::dateSetNow(), + 'title_prefix' => Operator::concat(' - Updated'), + 'views_modulo' => Operator::modulo(3), + 'score_power' => Operator::power(2, 1000), + 'age' => 30 + ]; + + $result = Operator::extractOperators($data); + + $operators = $result['operators']; + $updates = $result['updates']; + + // Check operators count (all fields except 'name' and 'age') + $this->assertCount(15, $operators); + + // Check that array methods are properly extracted + $this->assertInstanceOf(Operator::class, $operators['tags']); + $this->assertEquals('tags', $operators['tags']->getAttribute()); + $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operators['tags']->getMethod()); + + $this->assertInstanceOf(Operator::class, $operators['blacklist']); + $this->assertEquals('blacklist', $operators['blacklist']->getAttribute()); + $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operators['blacklist']->getMethod()); + + // Check string operators + $this->assertEquals(Operator::TYPE_CONCAT, $operators['title']->getMethod()); + $this->assertEquals(Operator::TYPE_REPLACE, $operators['content']->getMethod()); + + // Check math operators + $this->assertEquals(Operator::TYPE_MULTIPLY, $operators['views']->getMethod()); + $this->assertEquals(Operator::TYPE_DIVIDE, $operators['rating']->getMethod()); + + // Check boolean operator + $this->assertEquals(Operator::TYPE_TOGGLE, $operators['featured']->getMethod()); + + // Check new operators + $this->assertEquals(Operator::TYPE_CONCAT, $operators['title_prefix']->getMethod()); + $this->assertEquals(Operator::TYPE_MODULO, $operators['views_modulo']->getMethod()); + $this->assertEquals(Operator::TYPE_POWER, $operators['score_power']->getMethod()); + + // Check date operator + $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operators['last_modified']->getMethod()); + + // Check that max/min values are preserved + $this->assertEquals([5, 100], $operators['count']->getValues()); + $this->assertEquals([1, 0], $operators['score']->getValues()); + + // Check updates + $this->assertEquals(['name' => 'John', 'age' => 30], $updates); + } + + + public function testParsingWithNewConstants(): void + { + // Test parsing new array methods + $arrayRemove = [ + 'method' => Operator::TYPE_ARRAY_REMOVE, + 'attribute' => 'blacklist', + 'values' => ['spam'] + ]; + + $operator = Operator::parseOperator($arrayRemove); + $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operator->getMethod()); + $this->assertEquals('blacklist', $operator->getAttribute()); + $this->assertEquals(['spam'], $operator->getValues()); + + // Test parsing increment with max + $incrementWithMax = [ + 'method' => Operator::TYPE_INCREMENT, + 'attribute' => 'score', + 'values' => [1, 10] + ]; + + $operator = Operator::parseOperator($incrementWithMax); + $this->assertEquals([1, 10], $operator->getValues()); + } + + // Edge case tests + + public function testIncrementMaxLimitEdgeCases(): void + { + // Test that max limit is properly stored + $operator = Operator::increment(5, 10); + $values = $operator->getValues(); + $this->assertEquals(5, $values[0]); // increment value + $this->assertEquals(10, $values[1]); // max limit + + // Test with float values + $operator = Operator::increment(1.5, 9.9); + $values = $operator->getValues(); + $this->assertEquals(1.5, $values[0]); + $this->assertEquals(9.9, $values[1]); + + // Test with negative max (edge case) + $operator = Operator::increment(1, -5); + $values = $operator->getValues(); + $this->assertEquals(1, $values[0]); + $this->assertEquals(-5, $values[1]); + } + + public function testDecrementMinLimitEdgeCases(): void + { + // Test that min limit is properly stored + $operator = Operator::decrement(3, 0); + $values = $operator->getValues(); + $this->assertEquals(3, $values[0]); // decrement value + $this->assertEquals(0, $values[1]); // min limit + + // Test with float values + $operator = Operator::decrement(2.5, 0.1); + $values = $operator->getValues(); + $this->assertEquals(2.5, $values[0]); + $this->assertEquals(0.1, $values[1]); + + // Test with negative min + $operator = Operator::decrement(1, -10); + $values = $operator->getValues(); + $this->assertEquals(1, $values[0]); + $this->assertEquals(-10, $values[1]); + } + + public function testArrayRemoveEdgeCases(): void + { + // Test removing various types of values + $operator = Operator::arrayRemove('string'); + $this->assertEquals('string', $operator->getValue()); + + $operator = Operator::arrayRemove(42); + $this->assertEquals(42, $operator->getValue()); + + $operator = Operator::arrayRemove(null); + $this->assertEquals(null, $operator->getValue()); + + $operator = Operator::arrayRemove(true); + $this->assertEquals(true, $operator->getValue()); + + // Test removing array (nested array) + $operator = Operator::arrayRemove(['nested']); + $this->assertEquals(['nested'], $operator->getValue()); + } + + public function testOperatorCloningWithNewMethods(): void + { + // Test cloning increment with max + $operator1 = Operator::increment(5, 10); + $operator2 = clone $operator1; + + $this->assertEquals($operator1->getValues(), $operator2->getValues()); + $this->assertEquals([5, 10], $operator2->getValues()); + + // Modify one to ensure they're separate objects + $operator2->setValues([3, 8]); + $this->assertEquals([5, 10], $operator1->getValues()); + $this->assertEquals([3, 8], $operator2->getValues()); + + // Test cloning arrayRemove + $removeOp1 = Operator::arrayRemove('spam'); + $removeOp2 = clone $removeOp1; + + $this->assertEquals($removeOp1->getValue(), $removeOp2->getValue()); + $removeOp2->setValue('ham'); + $this->assertEquals('spam', $removeOp1->getValue()); + $this->assertEquals('ham', $removeOp2->getValue()); + } + + public function testSerializationWithNewOperators(): void + { + // Test serialization of increment with max + $operator = Operator::increment(5, 100); + $operator->setAttribute('score'); + + $array = $operator->toArray(); + $expected = [ + 'method' => Operator::TYPE_INCREMENT, + 'attribute' => 'score', + 'values' => [5, 100] + ]; + $this->assertEquals($expected, $array); + + // Test serialization of arrayRemove + $operator = Operator::arrayRemove('unwanted'); + $operator->setAttribute('blacklist'); + + $array = $operator->toArray(); + $expected = [ + 'method' => Operator::TYPE_ARRAY_REMOVE, + 'attribute' => 'blacklist', + 'values' => ['unwanted'] + ]; + $this->assertEquals($expected, $array); + + // Ensure JSON serialization works + $json = $operator->toString(); + $this->assertJson($json); + $decoded = json_decode($json, true); + $this->assertEquals($expected, $decoded); + } + + public function testMixedOperatorTypes(): void + { + // Test that all new operator types can coexist + $data = [ + 'arrayAppend' => Operator::arrayAppend(['new']), + 'incrementWithMax' => Operator::increment(1, 10), + 'decrementWithMin' => Operator::decrement(2, 0), + 'multiply' => Operator::multiply(3, 100), + 'divide' => Operator::divide(2, 1), + 'concat' => Operator::concat(' suffix'), + 'replace' => Operator::replace('old', 'new'), + 'toggle' => Operator::toggle(), + 'dateSetNow' => Operator::dateSetNow(), + 'modulo' => Operator::modulo(3), + 'power' => Operator::power(2), + 'remove' => Operator::arrayRemove('bad'), + ]; + + $result = Operator::extractOperators($data); + $operators = $result['operators']; + + $this->assertCount(12, $operators); + + // Verify each operator type + $this->assertEquals(Operator::TYPE_ARRAY_APPEND, $operators['arrayAppend']->getMethod()); + $this->assertEquals(Operator::TYPE_INCREMENT, $operators['incrementWithMax']->getMethod()); + $this->assertEquals([1, 10], $operators['incrementWithMax']->getValues()); + $this->assertEquals(Operator::TYPE_DECREMENT, $operators['decrementWithMin']->getMethod()); + $this->assertEquals([2, 0], $operators['decrementWithMin']->getValues()); + $this->assertEquals(Operator::TYPE_MULTIPLY, $operators['multiply']->getMethod()); + $this->assertEquals([3, 100], $operators['multiply']->getValues()); + $this->assertEquals(Operator::TYPE_DIVIDE, $operators['divide']->getMethod()); + $this->assertEquals([2, 1], $operators['divide']->getValues()); + $this->assertEquals(Operator::TYPE_CONCAT, $operators['concat']->getMethod()); + $this->assertEquals(Operator::TYPE_REPLACE, $operators['replace']->getMethod()); + $this->assertEquals(Operator::TYPE_TOGGLE, $operators['toggle']->getMethod()); + $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operators['dateSetNow']->getMethod()); + $this->assertEquals(Operator::TYPE_CONCAT, $operators['concat']->getMethod()); + $this->assertEquals(Operator::TYPE_MODULO, $operators['modulo']->getMethod()); + $this->assertEquals(Operator::TYPE_POWER, $operators['power']->getMethod()); + $this->assertEquals(Operator::TYPE_ARRAY_REMOVE, $operators['remove']->getMethod()); + } + + public function testTypeValidationWithNewMethods(): void + { + // All new array methods should be detected as array operations + $this->assertTrue(Operator::arrayAppend([])->isArrayOperation()); + $this->assertTrue(Operator::arrayPrepend([])->isArrayOperation()); + $this->assertTrue(Operator::arrayInsert(0, 'value')->isArrayOperation()); + $this->assertTrue(Operator::arrayRemove('value')->isArrayOperation()); + + // None should be detected as numeric operations + $this->assertFalse(Operator::arrayAppend([])->isNumericOperation()); + $this->assertFalse(Operator::arrayPrepend([])->isNumericOperation()); + $this->assertFalse(Operator::arrayInsert(0, 'value')->isNumericOperation()); + $this->assertFalse(Operator::arrayRemove('value')->isNumericOperation()); + + // Test numeric operations + $this->assertTrue(Operator::multiply(2)->isNumericOperation()); + $this->assertTrue(Operator::divide(2)->isNumericOperation()); + $this->assertFalse(Operator::multiply(2)->isArrayOperation()); + $this->assertFalse(Operator::divide(2)->isArrayOperation()); + + // Test string operations + $this->assertTrue(Operator::concat('test')->isStringOperation()); + $this->assertTrue(Operator::replace('old', 'new')->isStringOperation()); + $this->assertFalse(Operator::concat('test')->isNumericOperation()); + $this->assertFalse(Operator::replace('old', 'new')->isArrayOperation()); + + // Test boolean operations + $this->assertTrue(Operator::toggle()->isBooleanOperation()); + $this->assertFalse(Operator::toggle()->isNumericOperation()); + $this->assertFalse(Operator::toggle()->isArrayOperation()); + + + // Test date operations + $this->assertTrue(Operator::dateSetNow()->isDateOperation()); + $this->assertFalse(Operator::dateSetNow()->isNumericOperation()); + } + + // New comprehensive tests for all operators + + public function testStringOperators(): void + { + // Test concat operator + $operator = Operator::concat(' - Updated'); + $this->assertEquals(Operator::TYPE_CONCAT, $operator->getMethod()); + $this->assertEquals([' - Updated'], $operator->getValues()); + $this->assertEquals(' - Updated', $operator->getValue()); + $this->assertEquals('', $operator->getAttribute()); + + // Test concat with different values + $operator = Operator::concat('prefix-'); + $this->assertEquals(Operator::TYPE_CONCAT, $operator->getMethod()); + $this->assertEquals(['prefix-'], $operator->getValues()); + $this->assertEquals('prefix-', $operator->getValue()); + + // Test replace operator + $operator = Operator::replace('old', 'new'); + $this->assertEquals(Operator::TYPE_REPLACE, $operator->getMethod()); + $this->assertEquals(['old', 'new'], $operator->getValues()); + $this->assertEquals('old', $operator->getValue()); + } + + public function testMathOperators(): void + { + // Test multiply operator + $operator = Operator::multiply(2.5, 100); + $this->assertEquals(Operator::TYPE_MULTIPLY, $operator->getMethod()); + $this->assertEquals([2.5, 100], $operator->getValues()); + $this->assertEquals(2.5, $operator->getValue()); + + // Test multiply without max + $operator = Operator::multiply(3); + $this->assertEquals([3], $operator->getValues()); + + // Test divide operator + $operator = Operator::divide(2, 1); + $this->assertEquals(Operator::TYPE_DIVIDE, $operator->getMethod()); + $this->assertEquals([2, 1], $operator->getValues()); + $this->assertEquals(2, $operator->getValue()); + + // Test divide without min + $operator = Operator::divide(4); + $this->assertEquals([4], $operator->getValues()); + + // Test modulo operator + $operator = Operator::modulo(3); + $this->assertEquals(Operator::TYPE_MODULO, $operator->getMethod()); + $this->assertEquals([3], $operator->getValues()); + $this->assertEquals(3, $operator->getValue()); + + // Test power operator + $operator = Operator::power(2, 1000); + $this->assertEquals(Operator::TYPE_POWER, $operator->getMethod()); + $this->assertEquals([2, 1000], $operator->getValues()); + $this->assertEquals(2, $operator->getValue()); + + // Test power without max + $operator = Operator::power(3); + $this->assertEquals([3], $operator->getValues()); + } + + public function testDivideByZero(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Division by zero is not allowed'); + Operator::divide(0); + } + + public function testModuloByZero(): void + { + $this->expectException(OperatorException::class); + $this->expectExceptionMessage('Modulo by zero is not allowed'); + Operator::modulo(0); + } + + public function testBooleanOperator(): void + { + $operator = Operator::toggle(); + $this->assertEquals(Operator::TYPE_TOGGLE, $operator->getMethod()); + $this->assertEquals([], $operator->getValues()); + $this->assertNull($operator->getValue()); + } + + + public function testUtilityOperators(): void + { + // Test dateSetNow + $operator = Operator::dateSetNow(); + $this->assertEquals(Operator::TYPE_DATE_SET_NOW, $operator->getMethod()); + $this->assertEquals([], $operator->getValues()); + $this->assertNull($operator->getValue()); + } + + + public function testNewOperatorParsing(): void + { + // Test parsing all new operators + $operators = [ + ['method' => Operator::TYPE_CONCAT, 'attribute' => 'title', 'values' => [' - Updated']], + ['method' => Operator::TYPE_CONCAT, 'attribute' => 'subtitle', 'values' => [' - Updated']], // Deprecated + ['method' => Operator::TYPE_REPLACE, 'attribute' => 'content', 'values' => ['old', 'new']], + ['method' => Operator::TYPE_MULTIPLY, 'attribute' => 'score', 'values' => [2, 100]], + ['method' => Operator::TYPE_DIVIDE, 'attribute' => 'rating', 'values' => [2, 1]], + ['method' => Operator::TYPE_MODULO, 'attribute' => 'remainder', 'values' => [3]], + ['method' => Operator::TYPE_POWER, 'attribute' => 'exponential', 'values' => [2, 1000]], + ['method' => Operator::TYPE_TOGGLE, 'attribute' => 'active', 'values' => []], + ['method' => Operator::TYPE_DATE_SET_NOW, 'attribute' => 'updated', 'values' => []], + ]; + + foreach ($operators as $operatorData) { + $operator = Operator::parseOperator($operatorData); + $this->assertEquals($operatorData['method'], $operator->getMethod()); + $this->assertEquals($operatorData['attribute'], $operator->getAttribute()); + $this->assertEquals($operatorData['values'], $operator->getValues()); + + // Test JSON serialization round-trip + $json = $operator->toString(); + $parsed = Operator::parse($json); + $this->assertEquals($operator->getMethod(), $parsed->getMethod()); + $this->assertEquals($operator->getAttribute(), $parsed->getAttribute()); + $this->assertEquals($operator->getValues(), $parsed->getValues()); + } + } + + public function testOperatorCloning(): void + { + // Test cloning all new operator types + $operators = [ + Operator::concat(' suffix'), + Operator::concat(' suffix'), // Deprecated + Operator::replace('old', 'new'), + Operator::multiply(2, 100), + Operator::divide(2, 1), + Operator::modulo(3), + Operator::power(2, 100), + Operator::toggle(), + Operator::dateSetNow(), + ]; + + foreach ($operators as $operator) { + $cloned = clone $operator; + $this->assertEquals($operator->getMethod(), $cloned->getMethod()); + $this->assertEquals($operator->getAttribute(), $cloned->getAttribute()); + $this->assertEquals($operator->getValues(), $cloned->getValues()); + + // Ensure they are different objects + $cloned->setAttribute('different'); + $this->assertNotEquals($operator->getAttribute(), $cloned->getAttribute()); + } + } + + // Test edge cases and error conditions + + public function testOperatorEdgeCases(): void + { + // Test multiply with zero + $operator = Operator::multiply(0); + $this->assertEquals(0, $operator->getValue()); + + // Test divide with fraction + $operator = Operator::divide(0.5, 0.1); + $this->assertEquals([0.5, 0.1], $operator->getValues()); + + // Test concat with empty string + $operator = Operator::concat(''); + $this->assertEquals('', $operator->getValue()); + + // Test concat with empty string (deprecated) + $operator = Operator::concat(''); + $this->assertEquals('', $operator->getValue()); + + // Test replace with same strings + $operator = Operator::replace('same', 'same'); + $this->assertEquals(['same', 'same'], $operator->getValues()); + + // Test modulo edge cases + $operator = Operator::modulo(1.5); + $this->assertEquals(1.5, $operator->getValue()); + + // Test power with zero exponent + $operator = Operator::power(0); + $this->assertEquals(0, $operator->getValue()); + } + + public function testPowerOperatorWithMax(): void + { + // Test power with max limit + $operator = Operator::power(2, 1000); + $this->assertEquals(Operator::TYPE_POWER, $operator->getMethod()); + $this->assertEquals([2, 1000], $operator->getValues()); + + // Test power without max + $operator = Operator::power(3); + $this->assertEquals([3], $operator->getValues()); + } + + public function testOperatorTypeValidation(): void + { + // Test that operators have proper type checking methods + $numericOp = Operator::power(2); + $this->assertTrue($numericOp->isNumericOperation()); + $this->assertFalse($numericOp->isArrayOperation()); + $this->assertFalse($numericOp->isStringOperation()); + $this->assertFalse($numericOp->isBooleanOperation()); + $this->assertFalse($numericOp->isDateOperation()); + + $moduloOp = Operator::modulo(5); + $this->assertTrue($moduloOp->isNumericOperation()); + $this->assertFalse($moduloOp->isArrayOperation()); + } +}