diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 7a629c695..1a9770085 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -979,6 +979,13 @@ abstract public function getSupportForIndexArray(): bool; */ abstract public function getSupportForCastIndexArray(): bool; + /** + * Does the adapter use GIN indexes for array columns? + * + * @return bool + */ + abstract public function getSupportForGinIndex(): bool; + /** * Is unique index supported? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 1bd8797c9..2e45a0c38 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1684,7 +1684,7 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool return $this->getSpatialSQLType($type, $required); } if ($array === true) { - return 'JSON'; + return 'JSON NOT NULL DEFAULT (JSON_ARRAY())'; } switch ($type) { @@ -2082,12 +2082,12 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind case Operator::TYPE_ARRAY_APPEND: $bindKey = "op_{$bindIndex}"; $bindIndex++; - return "{$quotedColumn} = JSON_MERGE_PRESERVE(IFNULL({$quotedColumn}, JSON_ARRAY()), :$bindKey)"; + return "{$quotedColumn} = JSON_MERGE_PRESERVE({$quotedColumn}, :$bindKey)"; case Operator::TYPE_ARRAY_PREPEND: $bindKey = "op_{$bindIndex}"; $bindIndex++; - return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, IFNULL({$quotedColumn}, JSON_ARRAY()))"; + return "{$quotedColumn} = JSON_MERGE_PRESERVE(:$bindKey, {$quotedColumn})"; case Operator::TYPE_ARRAY_INSERT: $indexKey = "op_{$bindIndex}"; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 52acc9541..b5bae1d05 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3221,6 +3221,11 @@ public function getSupportForCastIndexArray(): bool return false; } + public function getSupportForGinIndex(): bool + { + return false; + } + public function getSupportForUpserts(): bool { return true; diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 3128d97ed..36784870f 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -388,6 +388,11 @@ public function getSupportForCastIndexArray(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForGinIndex(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getSupportForUniqueIndex(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 2af11aea3..c11823675 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -892,6 +892,26 @@ public function createIndex(string $collection, string $id, string $type, array $collection = $this->filter($collection); $id = $this->filter($id); + // JSONB array columns need GIN indexes for containment queries (@>). + $isArrayIndex = false; + if ($type === Database::INDEX_KEY) { + $metadataCollection = new Document(['$id' => Database::METADATA]); + $collectionDoc = $this->getDocument($metadataCollection, $collection); + if (!$collectionDoc->isEmpty()) { + $collectionAttributes = \json_decode($collectionDoc->getAttribute('attributes', '[]'), true); + $arrayCount = 0; + foreach ($attributes as $attr) { + foreach ($collectionAttributes as $collectionAttribute) { + if (\strtolower($collectionAttribute['$id']) === \strtolower($attr) && !empty($collectionAttribute['array'])) { + $arrayCount++; + break; + } + } + } + $isArrayIndex = $arrayCount > 0 && $arrayCount === \count($attributes); + } + } + foreach ($attributes as $i => $attr) { $order = empty($orders[$i]) || Database::INDEX_FULLTEXT === $type ? '' : $orders[$i]; $isNestedPath = isset($indexAttributeTypes[$attr]) && \str_contains($attr, '.') && $indexAttributeTypes[$attr] === Database::VAR_OBJECT; @@ -933,13 +953,13 @@ public function createIndex(string $collection, string $id, string $type, array $sql = "CREATE {$sqlType} \"{$keyName}\" ON {$this->getSQLTable($collection)}"; // Add USING clause for special index types - $sql .= match ($type) { - Database::INDEX_SPATIAL => " USING GIST ({$attributes})", - Database::INDEX_HNSW_EUCLIDEAN => " USING HNSW ({$attributes} vector_l2_ops)", - Database::INDEX_HNSW_COSINE => " USING HNSW ({$attributes} vector_cosine_ops)", - Database::INDEX_HNSW_DOT => " USING HNSW ({$attributes} vector_ip_ops)", - Database::INDEX_OBJECT => " USING GIN ({$attributes})", - Database::INDEX_TRIGRAM => + $sql .= match (true) { + $type === Database::INDEX_SPATIAL => " USING GIST ({$attributes})", + $type === Database::INDEX_HNSW_EUCLIDEAN => " USING HNSW ({$attributes} vector_l2_ops)", + $type === Database::INDEX_HNSW_COSINE => " USING HNSW ({$attributes} vector_cosine_ops)", + $type === Database::INDEX_HNSW_DOT => " USING HNSW ({$attributes} vector_ip_ops)", + $type === Database::INDEX_OBJECT, $isArrayIndex => " USING GIN ({$attributes})", + $type === Database::INDEX_TRIGRAM => " USING GIN (" . implode(', ', array_map( fn ($attr) => "$attr gin_trgm_ops", array_map(fn ($attr) => trim($attr), explode(',', $attributes)) @@ -1937,7 +1957,7 @@ protected function getFulltextValue(string $value): string protected function getSQLType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string { if ($array === true) { - return 'JSONB'; + return "JSONB NOT NULL DEFAULT '[]'::jsonb"; } switch ($type) { @@ -2117,6 +2137,16 @@ public function getSupportForTimeouts(): bool return true; } + public function getSupportForCastIndexArray(): bool + { + return true; + } + + public function getSupportForGinIndex(): bool + { + return true; + } + /** * Does the adapter handle Query Array Overlaps? * @@ -2688,12 +2718,12 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind case Operator::TYPE_ARRAY_APPEND: $bindKey = "op_{$bindIndex}"; $bindIndex++; - return "{$quotedColumn} = COALESCE({$columnRef}, '[]'::jsonb) || :$bindKey::jsonb"; + return "{$quotedColumn} = {$columnRef} || :$bindKey::jsonb"; case Operator::TYPE_ARRAY_PREPEND: $bindKey = "op_{$bindIndex}"; $bindIndex++; - return "{$quotedColumn} = :$bindKey::jsonb || COALESCE({$columnRef}, '[]'::jsonb)"; + return "{$quotedColumn} = :$bindKey::jsonb || {$columnRef}"; case Operator::TYPE_ARRAY_UNIQUE: return "{$quotedColumn} = COALESCE(( diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 702c1ea2a..7a82ee202 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1545,6 +1545,11 @@ public function getSupportForCastIndexArray(): bool return false; } + public function getSupportForGinIndex(): bool + { + return false; + } + public function getSupportForRelationships(): bool { return true; @@ -2599,8 +2604,12 @@ public function upsertDocuments( $spatialAttributes = $this->getSpatialAttributes($collection); $attributeDefaults = []; + $arrayAttributes = []; foreach ($collection->getAttribute('attributes', []) as $attr) { $attributeDefaults[$attr['$id']] = $attr['default'] ?? null; + if ($attr['array'] ?? false) { + $arrayAttributes[$attr['$id']] = true; + } } $collection = $collection->getId(); @@ -2671,6 +2680,11 @@ public function upsertDocuments( foreach ($allColumnNames as $attributeKey) { $attrValue = $currentRegularAttributes[$attributeKey] ?? null; + // Array columns are NOT NULL DEFAULT '[]', so coerce null to empty array + if ($attrValue === null && isset($arrayAttributes[$attributeKey])) { + $attrValue = '[]'; + } + if (\is_array($attrValue)) { $attrValue = \json_encode($attrValue); } @@ -2798,6 +2812,11 @@ public function upsertDocuments( foreach ($allColumnNames as $attributeKey) { $attrValue = $currentRegularAttributes[$attributeKey] ?? null; + // Array columns are NOT NULL DEFAULT '[]', so coerce null to empty array + if ($attrValue === null && isset($arrayAttributes[$attributeKey])) { + $attrValue = '[]'; + } + if (\is_array($attrValue)) { $attrValue = \json_encode($attrValue); } diff --git a/src/Database/Database.php b/src/Database/Database.php index c9e3f263f..f4daec5cf 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8200,6 +8200,13 @@ public function encode(Document $collection, Document $document, bool $applyDefa // Continue on optional param with no default if (is_null($value) && is_null($default)) { + // Non-required array columns are NOT NULL with DEFAULT '[]', so coerce null to empty array. + // Required arrays must remain null so the Structure validator catches the missing value. + // Only coerce when applying defaults (inserts) to avoid clearing existing values during partial updates. + $required = $attribute['required'] ?? false; + if ($applyDefaults && $array && !$required) { + $document->setAttribute($key, []); + } continue; } diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 4baeba35b..250bf5171 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -49,6 +49,11 @@ abstract class Base extends TestCase */ abstract protected function getDatabase(): Database; + /** + * @return \Utopia\Database\PDO|\PDO|null + */ + abstract protected function getPDO(): mixed; + /** * @param string $collection * @param string $column diff --git a/tests/e2e/Adapter/MariaDBTest.php b/tests/e2e/Adapter/MariaDBTest.php index 923de242e..726c432d8 100644 --- a/tests/e2e/Adapter/MariaDBTest.php +++ b/tests/e2e/Adapter/MariaDBTest.php @@ -52,6 +52,11 @@ public function getDatabase(bool $fresh = false): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; diff --git a/tests/e2e/Adapter/MirrorTest.php b/tests/e2e/Adapter/MirrorTest.php index 31bf3f3b6..97fbe143c 100644 --- a/tests/e2e/Adapter/MirrorTest.php +++ b/tests/e2e/Adapter/MirrorTest.php @@ -313,6 +313,11 @@ public function testDeleteMirroredDocument(): void $this->assertTrue($database->getDestination()->getDocument('testDeleteMirroredDocument', $document->getId())->isEmpty()); } + protected function getPDO(): mixed + { + return self::$sourcePdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . self::$source->getDatabase() . "`.`" . self::$source->getNamespace() . "_" . $collection . "`"; diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index 1c7eb9237..4fc21ad63 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -98,6 +98,11 @@ public function testKeywords(): void $this->assertTrue(true); } + protected function getPDO(): mixed + { + return null; + } + protected function deleteColumn(string $collection, string $column): bool { return true; diff --git a/tests/e2e/Adapter/MySQLTest.php b/tests/e2e/Adapter/MySQLTest.php index 8e92bb216..d0e5218b3 100644 --- a/tests/e2e/Adapter/MySQLTest.php +++ b/tests/e2e/Adapter/MySQLTest.php @@ -58,6 +58,11 @@ public function getDatabase(): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; diff --git a/tests/e2e/Adapter/PoolTest.php b/tests/e2e/Adapter/PoolTest.php index 94c2d4147..eee758e6d 100644 --- a/tests/e2e/Adapter/PoolTest.php +++ b/tests/e2e/Adapter/PoolTest.php @@ -79,6 +79,18 @@ public function getDatabase(): Database return self::$database = $database; } + protected function getPDO(): mixed + { + $pdo = null; + self::$pool->use(function (Adapter $adapter) use (&$pdo) { + $class = new ReflectionClass($adapter); + $property = $class->getProperty('pdo'); + $property->setAccessible(true); + $pdo = $property->getValue($adapter); + }); + return $pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; diff --git a/tests/e2e/Adapter/PostgresTest.php b/tests/e2e/Adapter/PostgresTest.php index 58beaf64e..6db6ead1d 100644 --- a/tests/e2e/Adapter/PostgresTest.php +++ b/tests/e2e/Adapter/PostgresTest.php @@ -51,6 +51,11 @@ public function getDatabase(): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = '"' . $this->getDatabase()->getDatabase(). '"."' . $this->getDatabase()->getNamespace() . '_' . $collection . '"'; diff --git a/tests/e2e/Adapter/SQLiteTest.php b/tests/e2e/Adapter/SQLiteTest.php index 6061352e4..443067e66 100644 --- a/tests/e2e/Adapter/SQLiteTest.php +++ b/tests/e2e/Adapter/SQLiteTest.php @@ -55,6 +55,11 @@ public function getDatabase(): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index bf376d101..779c3f5c7 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -1620,10 +1620,11 @@ public function testArrayAttribute(): void $this->assertEquals('Invalid query: Cannot query contains on attribute "age" because it is not an array, string, or object.', $e->getMessage()); } + // Array columns are NOT NULL with DEFAULT empty array, so isNull returns nothing $documents = $database->find($collection, [ Query::isNull('long_size') ]); - $this->assertCount(1, $documents); + $this->assertCount(0, $documents); $documents = $database->find($collection, [ Query::contains('tv_show', ['love']) diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index ccf884f5c..adb885ddd 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -569,10 +569,10 @@ public function testSchemaAttributes(): void $attribute = $attributes['string_list']; $this->assertEquals('string_list', $attribute['$id']); - $this->assertTrue(in_array($attribute['dataType'], ['json', 'longtext'])); // mysql vs maria - $this->assertTrue(in_array($attribute['columnType'], ['json', 'longtext'])); + $this->assertTrue(in_array($attribute['dataType'], ['json', 'jsonb', 'longtext'])); + $this->assertTrue(in_array($attribute['columnType'], ['json', 'jsonb', 'longtext'])); $this->assertTrue(in_array($attribute['characterMaximumLength'], [null, '4294967295'])); - $this->assertEquals('YES', $attribute['isNullable']); + $this->assertEquals('NO', $attribute['isNullable']); $attribute = $attributes['dob']; $this->assertEquals('dob', $attribute['$id']); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index d1241ad26..dc8caad12 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -4403,6 +4403,63 @@ public function testEncodeDecode(): void new Document(['$id' => '3', 'label' => 'z']), ], $result->getAttribute('tags')); } + + /** + * Regression: encode() with applyDefaults:false must not coerce + * unspecified optional array attributes to [], which would clear + * their stored values during partial updates. + */ + public function testEncodePartialUpdatePreservesArrayAttributes(): void + { + $collection = new Document([ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('items'), + 'name' => 'Items', + 'attributes' => [ + [ + '$id' => ID::custom('title'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 256, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('tags'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 255, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => true, + 'filters' => [], + ], + ], + 'indexes' => [], + ]); + + // Partial update: only 'title' is provided, 'tags' is absent + $partialUpdate = new Document([ + '$id' => ID::custom('doc1'), + 'title' => 'Updated Title', + ]); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $result = $database->encode($collection, $partialUpdate, applyDefaults: false); + + $this->assertEquals('Updated Title', $result->getAttribute('title')); + $this->assertNull( + $result->getAttribute('tags'), + 'Unspecified optional array attribute must remain null during partial update, not be coerced to []' + ); + } + /** * @depends testGetDocument */ diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 3f5c101f6..f880ac12b 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -1048,4 +1048,161 @@ public function testTTLIndexDuplicatePrevention(): void // Cleanup $database->deleteCollection($col); } + + /** + * Verifies that array attributes backed by JSON columns are created as + * NOT NULL DEFAULT (empty array). This is required because: + * + * 1. MySQL bug #111037: the optimizer can skip functional indexes + * (CAST ... AS ARRAY) on nullable columns in complex query plans. + * 2. Correctness: JSON_CONTAINS(NULL, ...) returns NULL, not FALSE, + * so rows with NULL array columns are invisible to both positive + * and negative array queries (containsAny / notContains). + * + * @link https://bugs.mysql.com/bug.php?id=111037 + */ + public function testArrayAttributeNotNullSchema(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (!$adapter->getSupportForSchemaAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = 'arrayNotNull'; + + $database->createCollection($collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ]); + + $database->createAttribute($collection, 'tags', Database::VAR_STRING, 255, false, array: true); + + // Verify the physical column is NOT NULL via INFORMATION_SCHEMA + $pdo = $this->getPDO(); + + $ns = $database->getNamespace(); + $db = $database->getDatabase(); + $tableName = "{$ns}_{$collection}"; + + $stmt = $pdo->prepare( + 'SELECT IS_NULLABLE, COLUMN_DEFAULT ' + . 'FROM INFORMATION_SCHEMA.COLUMNS ' + . 'WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?' + ); + $stmt->execute([$db, $tableName, 'tags']); + $col = $stmt->fetch(\PDO::FETCH_ASSOC); + // PostgreSQL returns lowercase keys from INFORMATION_SCHEMA + $col = $col ? \array_change_key_case($col, CASE_UPPER) : false; + + $this->assertNotFalse($col, 'Column "tags" should exist in INFORMATION_SCHEMA'); + $this->assertEquals( + 'NO', + $col['IS_NULLABLE'], + 'Array (JSON) column must be NOT NULL so the optimizer can use multi-valued indexes ' + . 'and JSON_CONTAINS/JSON_OVERLAPS never encounter NULL' + ); + + $database->deleteCollection($collection); + } + + /** + * Verifies that the index on an array attribute is picked by the + * optimizer and that queries return correct results. + */ + public function testArrayIndexUsedInQuery(): void + { + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (!$adapter->getSupportForCastIndexArray()) { + $this->expectNotToPerformAssertions(); + return; + } + + $collection = 'arrayIndexExplain'; + + $database->createCollection($collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ]); + + $database->createAttribute($collection, 'tags', Database::VAR_STRING, 255, false, array: true); + $database->createIndex($collection, 'idx_tags', Database::INDEX_KEY, ['tags'], [255]); + + // PostgreSQL's planner needs a larger dataset to choose a GIN index over a sequential scan. + $total = $adapter->getSupportForGinIndex() ? 5000 : 500; + $documents = []; + for ($i = 0; $i < $total; $i++) { + $documents[] = new Document([ + '$id' => ID::unique(), + '$permissions' => [Permission::read(Role::any())], + 'tags' => ['tag_' . ($i % 50), 'common'], + ]); + } + + foreach (\array_chunk($documents, 100) as $chunk) { + $database->createDocuments($collection, $chunk); + } + + $pdo = $this->getPDO(); + $ns = $database->getNamespace(); + $db = $database->getDatabase(); + + if ($adapter->getSupportForGinIndex()) { + $table = "\"{$db}\".\"{$ns}_{$collection}\""; + + // Verify GIN index exists + $stmt = $pdo->prepare( + "SELECT indexdef FROM pg_indexes WHERE tablename = ? AND indexname LIKE ?" + ); + $stmt->execute(["{$ns}_{$collection}", '%idx_tags%']); + $indexDef = $stmt->fetchColumn(); + $this->assertNotFalse($indexDef, 'GIN index should exist for array column'); + $this->assertStringContainsString('gin', \strtolower($indexDef), 'Index on array column should be a GIN index'); + + // Update planner statistics and verify the GIN index is chosen + $pdo->prepare("ANALYZE {$table}")->execute(); + $stmt = $pdo->prepare("EXPLAIN SELECT * FROM {$table} WHERE \"tags\" @> '[\"tag_1\"]'::jsonb"); + $stmt->execute(); + $plan = \implode("\n", $stmt->fetchAll(\PDO::FETCH_COLUMN)); + $this->assertMatchesRegularExpression( + '/Bitmap Heap Scan|Index Scan/i', + $plan, + "PostgreSQL should use the GIN index for @> containment query. EXPLAIN output:\n" . $plan + ); + } else { + $table = "`{$db}`.`{$ns}_{$collection}`"; + + $pdo->prepare("ANALYZE TABLE {$table}")->execute(); + + $sql = "EXPLAIN SELECT * FROM {$table} WHERE JSON_CONTAINS(`tags`, '[\"tag_1\"]')"; + $stmt = $pdo->prepare($sql); + $stmt->execute(); + $explain = $stmt->fetchAll(); + + $usedKey = $explain[0]['key'] ?? null; + $this->assertNotNull( + $usedKey, + 'MySQL should use an index for JSON_CONTAINS on a NOT NULL JSON column. ' + . "EXPLAIN output:\n" . \print_r($explain, true) + ); + $this->assertStringContainsString( + 'idx_tags', + $usedKey, + "Expected the 'idx_tags' index to be used. EXPLAIN output:\n" . \print_r($explain, true) + ); + } + + // Verify the query also returns correct functional results + $expectedCount = $total / 50; // each tag_N appears once per 50 documents + $results = $database->find($collection, [ + Query::containsAny('tags', ['tag_1']), + ]); + $this->assertCount($expectedCount, $results); + + $database->deleteCollection($collection); + } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index 9182b8b8b..5ada5b10b 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -157,7 +157,7 @@ public function testZoo(): void 'price' => 1000, 'dateOfBirth' => '1975-06-12', 'isActive' => true, - 'integers' => null, + 'integers' => [], 'email' => null, 'enum' => null, 'ip' => '255.0.0.1', diff --git a/tests/e2e/Adapter/SharedTables/MariaDBTest.php b/tests/e2e/Adapter/SharedTables/MariaDBTest.php index f6574ab0d..54b195892 100644 --- a/tests/e2e/Adapter/SharedTables/MariaDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MariaDBTest.php @@ -67,6 +67,11 @@ public function getDatabase(bool $fresh = false): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; diff --git a/tests/e2e/Adapter/SharedTables/MongoDBTest.php b/tests/e2e/Adapter/SharedTables/MongoDBTest.php index 61904861c..3185c22b0 100644 --- a/tests/e2e/Adapter/SharedTables/MongoDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MongoDBTest.php @@ -100,6 +100,11 @@ public function testKeywords(): void $this->assertTrue(true); } + protected function getPDO(): mixed + { + return null; + } + protected function deleteColumn(string $collection, string $column): bool { return true; diff --git a/tests/e2e/Adapter/SharedTables/MySQLTest.php b/tests/e2e/Adapter/SharedTables/MySQLTest.php index 697c42c7e..dc78f7b56 100644 --- a/tests/e2e/Adapter/SharedTables/MySQLTest.php +++ b/tests/e2e/Adapter/SharedTables/MySQLTest.php @@ -69,6 +69,11 @@ public function getDatabase(): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`"; diff --git a/tests/e2e/Adapter/SharedTables/PostgresTest.php b/tests/e2e/Adapter/SharedTables/PostgresTest.php index cb9633c01..2d01a621c 100644 --- a/tests/e2e/Adapter/SharedTables/PostgresTest.php +++ b/tests/e2e/Adapter/SharedTables/PostgresTest.php @@ -64,6 +64,11 @@ public function getDatabase(): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = '"' . $this->getDatabase()->getDatabase() . '"."' . $this->getDatabase()->getNamespace() . '_' . $collection . '"'; diff --git a/tests/e2e/Adapter/SharedTables/SQLiteTest.php b/tests/e2e/Adapter/SharedTables/SQLiteTest.php index ea4a042ea..029de2053 100644 --- a/tests/e2e/Adapter/SharedTables/SQLiteTest.php +++ b/tests/e2e/Adapter/SharedTables/SQLiteTest.php @@ -70,6 +70,11 @@ public function getDatabase(): Database return self::$database = $database; } + protected function getPDO(): mixed + { + return self::$pdo; + } + protected function deleteColumn(string $collection, string $column): bool { $sqlTable = "`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`";