From c31f611b60f552d166cd9d9f8a2c19fb4bbd8996 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 31 Oct 2025 01:25:58 +1300 Subject: [PATCH 1/4] Fix validation --- bin/tasks/operators.php | 80 ++++- src/Database/Database.php | 121 +++----- src/Database/Validator/Operator.php | 8 +- src/Database/Validator/Structure.php | 17 +- tests/unit/Validator/OperatorTest.php | 403 +++++++++++++++++++++++++ tests/unit/Validator/StructureTest.php | 69 +++++ 6 files changed, 607 insertions(+), 91 deletions(-) create mode 100644 tests/unit/Validator/OperatorTest.php diff --git a/bin/tasks/operators.php b/bin/tasks/operators.php index cc7471788..2972c51d0 100644 --- a/bin/tasks/operators.php +++ b/bin/tasks/operators.php @@ -8,8 +8,11 @@ * * @example * docker compose exec tests bin/operators --adapter=mariadb --iterations=1000 - * docker compose exec tests bin/operators --adapter=postgres --iterations=1000 - * docker compose exec tests bin/operators --adapter=sqlite --iterations=1000 + * docker compose exec tests bin/operators --adapter=postgres --iterations=1000 --seed=10000 + * docker compose exec tests bin/operators --adapter=sqlite --iterations=1000 --seed=5000 + * + * The --seed parameter allows you to pre-populate the collection with a specified + * number of documents to test how operators perform with varying amounts of existing data. */ global $cli; @@ -38,8 +41,9 @@ ->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('seed', 0, new Integer(true), 'Number of documents to pre-seed the collection with', true) ->param('name', 'operator_benchmark_' . uniqid(), new Text(0), 'Name of test database', true) - ->action(function (string $adapter, int $iterations, string $name) { + ->action(function (string $adapter, int $iterations, int $seed, string $name) { $namespace = '_ns'; $cache = new Cache(new NoCache()); @@ -48,6 +52,7 @@ Console::info("============================================================="); Console::info("Adapter: {$adapter}"); Console::info("Iterations: {$iterations}"); + Console::info("Seed Documents: {$seed}"); Console::info("Database: {$name}"); Console::info("=============================================================\n"); @@ -110,13 +115,13 @@ ->setNamespace($namespace); // Setup test environment - setupTestEnvironment($database, $name); + setupTestEnvironment($database, $name, $seed); // Run all benchmarks $results = runAllBenchmarks($database, $iterations); // Display results - displayResults($results, $adapter, $iterations); + displayResults($results, $adapter, $iterations, $seed); // Cleanup cleanup($database, $name); @@ -133,7 +138,7 @@ /** * Setup test environment with collections and sample data */ -function setupTestEnvironment(Database $database, string $name): void +function setupTestEnvironment(Database $database, string $name, int $seed): void { Console::info("Setting up test environment..."); @@ -179,9 +184,69 @@ function setupTestEnvironment(Database $database, string $name): void $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']); + // Seed documents if requested + if ($seed > 0) { + seedDocuments($database, $seed); + } + Console::success("Test environment setup complete.\n"); } +/** + * Seed the collection with a specified number of documents + */ +function seedDocuments(Database $database, int $count): void +{ + Console::info("Seeding {$count} documents..."); + + $batchSize = 100; // Insert in batches for better performance + $batches = (int) ceil($count / $batchSize); + + $seedStart = microtime(true); + + for ($batch = 0; $batch < $batches; $batch++) { + $docs = []; + $remaining = min($batchSize, $count - ($batch * $batchSize)); + + for ($i = 0; $i < $remaining; $i++) { + $docNum = ($batch * $batchSize) + $i; + $docs[] = new Document([ + '$id' => 'seed_' . $docNum, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'counter' => rand(0, 1000), + 'score' => round(rand(0, 10000) / 100, 2), + 'multiplier' => round(rand(50, 200) / 100, 2), + 'divider' => round(rand(5000, 15000) / 100, 2), + 'modulo_val' => rand(50, 200), + 'power_val' => round(rand(100, 300) / 100, 2), + 'name' => 'seed_doc_' . $docNum, + 'text' => 'Seed text for document ' . $docNum, + 'description' => 'This is seed document ' . $docNum . ' with some foo bar baz content', + 'active' => (bool) rand(0, 1), + 'tags' => ['seed', 'tag' . ($docNum % 10), 'category' . ($docNum % 5)], + 'numbers' => [rand(1, 10), rand(11, 20), rand(21, 30)], + 'items' => ['item' . ($docNum % 3), 'item' . ($docNum % 7)], + 'created_at' => DateTime::now(), + 'updated_at' => DateTime::now(), + ]); + } + + // Bulk insert documents + $database->createDocuments('operators_test', $docs); + + // Show progress + $progress = (($batch + 1) * $batchSize); + $current = min($progress, $count); + Console::log(" Seeded {$current}/{$count} documents..."); + } + + $seedTime = microtime(true) - $seedStart; + Console::success("Seeding completed in " . number_format($seedTime, 2) . "s\n"); +} + /** * Run all operator benchmarks */ @@ -848,13 +913,14 @@ function benchmarkOperatorAcrossOperations( /** * Display formatted results table */ -function displayResults(array $results, string $adapter, int $iterations): void +function displayResults(array $results, string $adapter, int $iterations, int $seed): void { Console::info("\n============================================================="); Console::info(" BENCHMARK RESULTS"); Console::info("============================================================="); Console::info("Adapter: {$adapter}"); Console::info("Iterations per test: {$iterations}"); + Console::info("Seeded documents: {$seed}"); Console::info("=============================================================\n"); // ================================================================== diff --git a/src/Database/Database.php b/src/Database/Database.php index ea31d0db1..5b55d6005 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4952,20 +4952,7 @@ public function updateDocument(string $collection, string $id, Document $documen } $createdAt = $document->getCreatedAt(); - // Extract operators from the document before merging - $documentArray = $document->getArrayCopy(); - $extracted = Operator::extractOperators($documentArray); - $operators = $extracted['operators']; - $updates = $extracted['updates']; - - $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 = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID $document['$createdAt'] = ($createdAt === null || !$this->preserveDates) ? $old->getCreatedAt() : $createdAt; @@ -4989,8 +4976,11 @@ public function updateDocument(string $collection, string $id, Document $documen $relationships[$relationship->getAttribute('key')] = $relationship; } - if (!empty($operators)) { - $shouldUpdate = true; + foreach ($document as $key => $value) { + if (Operator::isOperator($value)) { + $shouldUpdate = true; + break; + } } // Compare if the document has any changes @@ -5110,7 +5100,8 @@ public function updateDocument(string $collection, string $id, Document $documen $this->adapter->getIdAttributeType(), $this->adapter->getMinDateTime(), $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() + $this->adapter->getSupportForAttributes(), + $old ); if (!$structureValidator->isValid($document)) { // Make sure updated structure still apply collection rules (if any) throw new StructureException($structureValidator->getDescription()); @@ -5120,14 +5111,8 @@ public function updateDocument(string $collection, string $id, Document $documen $document = $this->silent(fn () => $this->updateDocumentRelationships($collection, $old, $document)); } - $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); @@ -5135,7 +5120,15 @@ public function updateDocument(string $collection, string $id, Document $documen $this->purgeCachedDocument($collection->getId(), $id); // If operators were used, refetch document to get computed values - if (!empty($operators)) { + $hasOperators = false; + foreach ($document->getArrayCopy() as $value) { + if (Operator::isOperator($value)) { + $hasOperators = true; + break; + } + } + + if ($hasOperators) { $refetched = $this->refetchDocuments($collection, [$document]); $document = $refetched[0]; } @@ -5258,24 +5251,17 @@ public function updateDocuments( applyDefaults: false ); - // Separate operators from regular updates for validation - $extracted = Operator::extractOperators($updates->getArrayCopy()); - $operators = $extracted['operators']; - $regularUpdates = $extracted['updates']; - - // 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() - ); + $validator = new PartialStructure( + $collection, + $this->adapter->getIdAttributeType(), + $this->adapter->getMinDateTime(), + $this->adapter->getMaxDateTime(), + $this->adapter->getSupportForAttributes(), + null // No old document available in bulk updates + ); - if (!$validator->isValid(new Document($regularUpdates))) { - throw new StructureException($validator->getDescription()); - } + if (!$validator->isValid($updates)) { + throw new StructureException($validator->getDescription()); } $originalLimit = $limit; @@ -5311,17 +5297,8 @@ public function updateDocuments( $currentPermissions = $updates->getPermissions(); sort($currentPermissions); - $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions, $operators) { + $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions) { foreach ($batch as $index => $document) { - 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')) { @@ -5369,7 +5346,15 @@ public function updateDocuments( $updates = $this->adapter->castingBefore($collection, $updates); - if (!empty($operators)) { + $hasOperators = false; + foreach ($updates->getArrayCopy() as $value) { + if (Operator::isOperator($value)) { + $hasOperators = true; + break; + } + } + + if ($hasOperators) { $batch = $this->refetchDocuments($collection, $batch); } @@ -6035,45 +6020,19 @@ public function upsertDocumentsWithIncrease( } } - // Extract operators for validation - $documentArray = $document->getArrayCopy(); - $extracted = Operator::extractOperators($documentArray); - $operators = $extracted['operators']; - $regularUpdates = $extracted['updates']; - - $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, $this->adapter->getIdAttributeType(), $this->adapter->getMinDateTime(), $this->adapter->getMaxDateTime(), - $this->adapter->getSupportForAttributes() + $this->adapter->getSupportForAttributes(), + $old->isEmpty() ? null : $old ); - if (!$validator->isValid($encodedTemp)) { + if (!$validator->isValid($document)) { throw new StructureException($validator->getDescription()); } - // Now encode the full document with operators for the adapter $document = $this->encode($collection, $document); if (!$old->isEmpty()) { diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 08e6ea548..e43ebf26c 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -60,8 +60,12 @@ public function getDescription(): string public function isValid($value): bool { if (!$value instanceof DatabaseOperator) { - $this->message = 'Value must be an instance of Operator'; - return false; + try { + $value = DatabaseOperator::parse($value); + } catch (\Throwable $e) { + $this->message = 'Invalid operator: ' . $e->getMessage(); + return false; + } } $method = $value->getMethod(); diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 26214d0bf..5c6005359 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -7,7 +7,9 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Operator; use Utopia\Database\Validator\Datetime as DatetimeValidator; +use Utopia\Database\Validator\Operator as OperatorValidator; use Utopia\Validator; use Utopia\Validator\Boolean; use Utopia\Validator\FloatValidator; @@ -106,7 +108,8 @@ public function __construct( private readonly string $idAttributeType, private readonly \DateTime $minAllowedDate = new \DateTime('0000-01-01'), private readonly \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), - private bool $supportForAttributes = true + private bool $supportForAttributes = true, + private readonly ?Document $currentDocument = null ) { } @@ -305,6 +308,18 @@ protected function checkForUnknownAttributes(array $structure, array $keys): boo protected function checkForInvalidAttributeValues(array $structure, array $keys): bool { foreach ($structure as $key => $value) { + if (Operator::isOperator($value)) { + // Set the attribute name on the operator for validation + $value->setAttribute($key); + + $operatorValidator = new OperatorValidator($this->collection, $this->currentDocument); + if (!$operatorValidator->isValid($value)) { + $this->message = $operatorValidator->getDescription(); + return false; + } + continue; + } + $attribute = $keys[$key] ?? []; $type = $attribute['type'] ?? ''; $array = $attribute['array'] ?? false; diff --git a/tests/unit/Validator/OperatorTest.php b/tests/unit/Validator/OperatorTest.php new file mode 100644 index 000000000..e89d39104 --- /dev/null +++ b/tests/unit/Validator/OperatorTest.php @@ -0,0 +1,403 @@ +collection = new Document([ + '$id' => 'test_collection', + 'attributes' => [ + new Document([ + '$id' => 'count', + 'key' => 'count', + 'type' => Database::VAR_INTEGER, + 'array' => false, + ]), + new Document([ + '$id' => 'score', + 'key' => 'score', + 'type' => Database::VAR_FLOAT, + 'array' => false, + ]), + new Document([ + '$id' => 'title', + 'key' => 'title', + 'type' => Database::VAR_STRING, + 'array' => false, + 'size' => 100, + ]), + new Document([ + '$id' => 'tags', + 'key' => 'tags', + 'type' => Database::VAR_STRING, + 'array' => true, + ]), + new Document([ + '$id' => 'active', + 'key' => 'active', + 'type' => Database::VAR_BOOLEAN, + 'array' => false, + ]), + new Document([ + '$id' => 'createdAt', + 'key' => 'createdAt', + 'type' => Database::VAR_DATETIME, + 'array' => false, + ]), + ], + ]); + } + + public function tearDown(): void + { + } + + // Test parsing string operators (new functionality) + public function testParseStringOperator(): void + { + $validator = new OperatorValidator($this->collection); + + // Create an operator and serialize it to JSON + $operator = Operator::increment(5); + $operator->setAttribute('count'); + $json = $operator->toString(); + + // Validator should accept JSON string and parse it + $this->assertTrue($validator->isValid($json), $validator->getDescription()); + } + + public function testParseInvalidStringOperator(): void + { + $validator = new OperatorValidator($this->collection); + + // Invalid JSON should fail + $this->assertFalse($validator->isValid('invalid json')); + $this->assertStringContainsString('Invalid operator:', $validator->getDescription()); + } + + public function testParseStringOperatorWithInvalidMethod(): void + { + $validator = new OperatorValidator($this->collection); + + // Valid JSON but invalid method + $invalidOperator = json_encode([ + 'method' => 'invalidMethod', + 'attribute' => 'count', + 'values' => [1] + ]); + + $this->assertFalse($validator->isValid($invalidOperator)); + $this->assertStringContainsString('Invalid operator method:', $validator->getDescription()); + } + + // Test numeric operators + public function testIncrementOperator(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::increment(5); + $operator->setAttribute('count'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testIncrementOnNonNumeric(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::increment(5); + $operator->setAttribute('title'); // String field + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Cannot apply increment operator to non-numeric field', $validator->getDescription()); + } + + public function testDecrementOperator(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::decrement(3); + $operator->setAttribute('count'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testMultiplyOperator(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::multiply(2.5); + $operator->setAttribute('score'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testDivideByZero(): void + { + $validator = new OperatorValidator($this->collection); + + // The divide helper method throws exception before validator is called + $this->expectException(\Utopia\Database\Exception\Operator::class); + $this->expectExceptionMessage('Division by zero is not allowed'); + + $operator = Operator::divide(0); + } + + public function testModuloByZero(): void + { + $validator = new OperatorValidator($this->collection); + + // The modulo helper method throws exception before validator is called + $this->expectException(\Utopia\Database\Exception\Operator::class); + $this->expectExceptionMessage('Modulo by zero is not allowed'); + + $operator = Operator::modulo(0); + } + + // Test array operators + public function testArrayAppend(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayAppend(['new-tag']); + $operator->setAttribute('tags'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testArrayAppendOnNonArray(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayAppend(['value']); + $operator->setAttribute('title'); // Non-array field + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Cannot apply arrayAppend operator to non-array field', $validator->getDescription()); + } + + public function testArrayUnique(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayUnique(); + $operator->setAttribute('tags'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testArrayUniqueOnNonArray(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayUnique(); + $operator->setAttribute('count'); // Non-array field + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Cannot apply arrayUnique operator to non-array field', $validator->getDescription()); + } + + public function testArrayIntersect(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayIntersect(['tag1', 'tag2']); + $operator->setAttribute('tags'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testArrayIntersectWithEmptyArray(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayIntersect([]); + $operator->setAttribute('tags'); + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('requires a non-empty array value', $validator->getDescription()); + } + + public function testArrayDiff(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayDiff(['unwanted']); + $operator->setAttribute('tags'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testArrayFilter(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayFilter('equal', 'active'); + $operator->setAttribute('tags'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testArrayFilterInvalidCondition(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::arrayFilter('invalidCondition', 'value'); + $operator->setAttribute('tags'); + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Invalid array filter condition', $validator->getDescription()); + } + + // Test string operators + public function testStringConcat(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::stringConcat(' - Updated'); + $operator->setAttribute('title'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testStringConcatOnNonString(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::stringConcat(' suffix'); + $operator->setAttribute('count'); // Non-string field + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Cannot apply stringConcat operator to non-string field', $validator->getDescription()); + } + + public function testStringReplace(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::stringReplace('old', 'new'); + $operator->setAttribute('title'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + // Test boolean operators + public function testToggle(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::toggle(); + $operator->setAttribute('active'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testToggleOnNonBoolean(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::toggle(); + $operator->setAttribute('count'); // Non-boolean field + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Cannot apply toggle operator to non-boolean field', $validator->getDescription()); + } + + // Test date operators + public function testDateAddDays(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::dateAddDays(7); + $operator->setAttribute('createdAt'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testDateAddDaysOnNonDateTime(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::dateAddDays(7); + $operator->setAttribute('count'); // Non-datetime field + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Cannot apply dateAddDays operator to non-datetime field', $validator->getDescription()); + } + + public function testDateSubDays(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::dateSubDays(3); + $operator->setAttribute('createdAt'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + public function testDateSubDaysOnNonDateTime(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::dateSubDays(3); + $operator->setAttribute('title'); // Non-datetime field + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString('Cannot apply dateSubDays operator to non-datetime field', $validator->getDescription()); + } + + public function testDateSetNow(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::dateSetNow(); + $operator->setAttribute('createdAt'); + + $this->assertTrue($validator->isValid($operator), $validator->getDescription()); + } + + // Test attribute validation + public function testNonExistentAttribute(): void + { + $validator = new OperatorValidator($this->collection); + + $operator = Operator::increment(1); + $operator->setAttribute('nonExistentField'); + + $this->assertFalse($validator->isValid($operator)); + $this->assertStringContainsString("Attribute 'nonExistentField' does not exist in collection", $validator->getDescription()); + } + + // Test multiple operators as strings (like Query validator does) + public function testMultipleStringOperators(): void + { + $validator = new OperatorValidator($this->collection); + + // Test various operators as JSON strings + $operators = [ + Operator::increment(1), + Operator::arrayAppend(['tag']), + Operator::stringConcat(' suffix'), + Operator::toggle(), + Operator::dateAddDays(7), + ]; + + $attributes = ['count', 'tags', 'title', 'active', 'createdAt']; + + foreach ($operators as $index => $operator) { + $operator->setAttribute($attributes[$index]); + $json = $operator->toString(); + $this->assertTrue($validator->isValid($json), "Failed for operator {$attributes[$index]}: " . $validator->getDescription()); + } + } +} diff --git a/tests/unit/Validator/StructureTest.php b/tests/unit/Validator/StructureTest.php index a0b448ff5..e4749d342 100644 --- a/tests/unit/Validator/StructureTest.php +++ b/tests/unit/Validator/StructureTest.php @@ -8,6 +8,7 @@ use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Helpers\ID; +use Utopia\Database\Operator; use Utopia\Database\Validator\Structure; class StructureTest extends TestCase @@ -780,4 +781,72 @@ public function testId(): void ]))); } + public function testOperatorsSkippedDuringValidation(): void + { + $validator = new Structure( + new Document($this->collection), + Database::VAR_INTEGER + ); + + // Operators should be skipped during structure validation + $this->assertTrue($validator->isValid(new Document([ + '$collection' => ID::custom('posts'), + 'title' => 'My Title', + 'description' => 'Demo description', + 'rating' => Operator::increment(1), // Operator on required field + 'price' => 1.99, + 'published' => true, + 'tags' => ['dog', 'cat', 'mouse'], + 'feedback' => 'team@appwrite.io', + '$createdAt' => '2000-04-01T12:00:00.000+00:00', + '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + ])), $validator->getDescription()); + } + + public function testMultipleOperatorsSkippedDuringValidation(): void + { + $validator = new Structure( + new Document($this->collection), + Database::VAR_INTEGER + ); + + // Multiple operators should all be skipped + $this->assertTrue($validator->isValid(new Document([ + '$collection' => ID::custom('posts'), + 'title' => Operator::stringConcat(' - Updated'), + 'description' => 'Demo description', + 'rating' => Operator::increment(1), + 'price' => Operator::multiply(2), + 'published' => Operator::toggle(), + 'tags' => Operator::arrayAppend(['new']), + 'feedback' => 'team@appwrite.io', + '$createdAt' => '2000-04-01T12:00:00.000+00:00', + '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + ])), $validator->getDescription()); + } + + public function testMissingRequiredFieldWithoutOperator(): void + { + $validator = new Structure( + new Document($this->collection), + Database::VAR_INTEGER + ); + + // Missing required field (not replaced by operator) should still fail + $this->assertFalse($validator->isValid(new Document([ + '$collection' => ID::custom('posts'), + 'title' => 'My Title', + 'description' => 'Demo description', + // 'rating' is missing entirely - should fail + 'price' => 1.99, + 'published' => true, + 'tags' => ['dog', 'cat', 'mouse'], + 'feedback' => 'team@appwrite.io', + '$createdAt' => '2000-04-01T12:00:00.000+00:00', + '$updatedAt' => '2000-04-01T12:00:00.000+00:00' + ]))); + + $this->assertEquals('Invalid document structure: Missing required attribute "rating"', $validator->getDescription()); + } + } From 78854fbbccdbe1297e036c7935ebc50597864aeb Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 31 Oct 2025 01:49:55 +1300 Subject: [PATCH 2/4] Use swoole images --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/linter.yml | 2 +- src/Database/Database.php | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a8874e4f2..3ad88e7e1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -16,5 +16,5 @@ jobs: - name: Run CodeQL run: | - docker run --rm -v $PWD:/app composer sh -c \ + docker run --rm -v $PWD:/app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ "composer install --profile --ignore-platform-reqs && composer check" \ No newline at end of file diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 7620013c8..134cbe661 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -16,5 +16,5 @@ jobs: - name: Run Linter run: | - docker run --rm -v $PWD:/app composer sh -c \ + docker run --rm -v $PWD:/app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ "composer install --profile --ignore-platform-reqs && composer lint" diff --git a/src/Database/Database.php b/src/Database/Database.php index 5b55d6005..cf2c4cdf0 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -27,7 +27,6 @@ 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; From 6f6544c6aa28171f5478f8b206304363df1b6b9e Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 31 Oct 2025 01:58:10 +1300 Subject: [PATCH 3/4] Set workdir --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/linter.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3ad88e7e1..161d9cebd 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -16,5 +16,5 @@ jobs: - name: Run CodeQL run: | - docker run --rm -v $PWD:/app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ + docker run --rm -v $PWD:/app -w /app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ "composer install --profile --ignore-platform-reqs && composer check" \ No newline at end of file diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 134cbe661..7148b95b7 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -16,5 +16,5 @@ jobs: - name: Run Linter run: | - docker run --rm -v $PWD:/app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ + docker run --rm -v $PWD:/app -w /app phpswoole/swoole:5.1.8-php8.3-alpine sh -c \ "composer install --profile --ignore-platform-reqs && composer lint" From 08a86fae76a2a1dc2f2a02445ef238acff6df603 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 31 Oct 2025 02:02:52 +1300 Subject: [PATCH 4/4] Fix stan ignore --- src/Database/Connection.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index cbf189df7..474d10a7f 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -21,7 +21,6 @@ class Connection */ public static function hasError(\Throwable $e): bool { - /** @phpstan-ignore-next-line can't find static method */ if (DetectsLostConnections::causedByLostConnection($e)) { return true; }