Skip to content

Comments

Non null arrays to allow index usage#819

Open
abnegate wants to merge 5 commits intomainfrom
fix-array-indexes
Open

Non null arrays to allow index usage#819
abnegate wants to merge 5 commits intomainfrom
fix-array-indexes

Conversation

@abnegate
Copy link
Member

@abnegate abnegate commented Feb 19, 2026

Summary by CodeRabbit

  • New Features

    • Indexing now prefers appropriate index types for JSON/array attributes; adapters expose Gin/index capability flags.
  • Bug Fixes

    • JSON/JSONB-backed array attributes are NOT NULL with an empty-array default.
    • Array append/prepend now merge directly with provided values.
    • Upserts/coercion ensure optional array nulls are coerced to empty arrays where applicable.
  • Tests

    • Added e2e and regression tests for array-schema, index usage, encoding/upsert behavior, and test DB accessors.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 19, 2026

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Array-backed JSON attributes are now NOT NULL with empty-array defaults; array append/prepend operators stop coalescing columns; adapters expose GIN-index capability; upsert/encode coerce null optional arrays to empty arrays; tests updated and PDO accessors added.

Changes

Cohort / File(s) Summary
Array Type Definitions
src/Database/Adapter/MariaDB.php, src/Database/Adapter/Postgres.php
getSQLType() now emits NOT NULL with empty-array defaults (MariaDB: JSON NOT NULL DEFAULT (JSON_ARRAY()); Postgres: JSONB NOT NULL DEFAULT '[]'::jsonb). Array append/prepend operators no longer wrap columns with IFNULL/COALESCE.
Postgres Index & Operator Enhancements
src/Database/Adapter/Postgres.php
Added detection for JSON/array index usage, revised USING-clause selection to route JSON/array/object paths to GIN where appropriate, adjusted array operator SQL, and added getSupportForCastIndexArray() and getSupportForGinIndex() methods.
SQL Adapter & Upsert Coercion
src/Database/Adapter/SQL.php
Added getSupportForGinIndex(): bool; upsertDocuments tracks array-typed attributes and coerces nulls to '[]' for array columns before JSON encoding/binding in both regular and operator-driven paths.
Database Encoding Change
src/Database/Database.php
encode() coerces null values for optional array attributes to an empty array (required arrays remain null for validation).
Adapter Contract & Implementations
src/Database/Adapter.php, src/Database/Adapter/Mongo.php, src/Database/Adapter/Pool.php
Added abstract getSupportForGinIndex(): bool to Adapter; implemented in Mongo (returns false) and Pool (delegates).
Tests — PDO Accessors
tests/e2e/Adapter/Base.php, tests/e2e/Adapter/*Test.php, tests/e2e/Adapter/SharedTables/*Test.php
Added abstract protected getPDO(): mixed to test base and implemented it across adapter tests (returns static PDO, null, or extracted PDO via pool).
Tests — Expectations & New Tests
tests/e2e/Adapter/Scopes/AttributeTests.php, tests/e2e/Adapter/Scopes/CollectionTests.php, tests/e2e/Adapter/Scopes/IndexTests.php, tests/e2e/Adapter/Scopes/RelationshipTests.php, tests/e2e/Adapter/Scopes/DocumentTests.php
Updated schema expectations (array columns now NOT NULL; jsonb included), adjusted sample data (null -> []), added tests verifying NOT NULL array schema and array-index usage, and added regression checking partial-update behavior for arrays.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • fogelito
  • ArnabChatterjee20k

Poem

🐰 I nibbled nulls, sowed brackets wide,
Now arrays stand tall, no more null to hide.
GIN signs hung high where indexes play,
Upserts hum gently and tests find their way.
A rabbit twitches — code hops on today.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Non null arrays to allow index usage' directly and clearly summarizes the main change: making array attributes NOT NULL to enable indexing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-array-indexes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Adapter/Postgres.php (1)

576-626: ⚠️ Potential issue | 🟠 Major

getSQLType includes NOT NULL/DEFAULT constraints unsuitable for ALTER COLUMN TYPE syntax.

When array=true, getSQLType() returns "JSONB NOT NULL DEFAULT '[]'::jsonb". PostgreSQL's ALTER COLUMN ... TYPE clause does not accept constraint modifiers—attempting to use this string directly in updateAttribute() generates invalid SQL and fails at runtime.

Separate the type from constraints: extract just JSONB for the TYPE clause, then apply DEFAULT and NOT NULL as separate ALTER COLUMN subcommands (backfilling NULLs before enforcing SET NOT NULL to avoid violations).

🛠️ Proposed fix
-        $type = $this->getSQLType(
+        $isArray = $array === true;
+        $type = $this->getSQLType(
             $type,
             $size,
             $signed,
             $array,
             $required,
         );
+        if ($isArray) {
+            // ALTER COLUMN TYPE cannot include NOT NULL/DEFAULT in Postgres
+            $type = 'JSONB';
+        }
 
         if ($type == 'TIMESTAMP(3)') {
             $type = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')";
         }
 
@@ -624,6 +634,19 @@
             $result = $this->execute($this->getPDO()
                 ->prepare($sql));
 
+            if ($isArray) {
+                // Backfill NULLs then enforce NOT NULL + DEFAULT
+                $table = $this->getSQLTable($name);
+                $this->execute($this->getPDO()->prepare(
+                    "UPDATE {$table} SET \"{$id}\" = '[]'::jsonb WHERE \"{$id}\" IS NULL"
+                ));
+                $this->execute($this->getPDO()->prepare(
+                    "ALTER TABLE {$table} ALTER COLUMN \"{$id}\" SET DEFAULT '[]'::jsonb"
+                ));
+                $this->execute($this->getPDO()->prepare(
+                    "ALTER TABLE {$table} ALTER COLUMN \"{$id}\" SET NOT NULL"
+                ));
+            }
             if (!$result) {
                 return false;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Postgres.php` around lines 576 - 626, The SQL returned
by getSQLType(...) can include modifiers (e.g., "JSONB NOT NULL DEFAULT
'[]'::jsonb") which break the ALTER COLUMN ... TYPE statement in
updateAttribute; modify updateAttribute to parse the string returned by
getSQLType($type, $size, $signed, $array, $required) into a base SQL type token
(e.g., "JSONB") and separate constraint pieces (NOT NULL and DEFAULT
expressions), then execute ALTER TABLE ... ALTER COLUMN "col" TYPE <baseType>
first, and afterwards apply constraints with separate ALTER COLUMN subcommands:
if DEFAULT present run ALTER COLUMN ... SET DEFAULT <expr>, if NOT NULL present
first run an UPDATE <table> SET "col" = <defaultExpr> WHERE "col" IS NULL to
backfill NULLs (when needed, e.g., JSONB default '[]'::jsonb), then run ALTER
COLUMN ... SET NOT NULL; reference updateAttribute and getSQLType to find where
to split the type string and apply these ordered ALTER statements.
🧹 Nitpick comments (2)
tests/e2e/Adapter/PoolTest.php (1)

82-92: Optional: deleteColumn, deleteIndex, and execRawSQL can reuse getPDO() now that it exists.

All three methods still inline the same five-line reflection block. Since the extracted getPDO() returns the same PDO object, they can be simplified:

♻️ Example simplification for deleteColumn
 protected function deleteColumn(string $collection, string $column): bool
 {
     $sqlTable = "`" . $this->getDatabase()->getDatabase() . "`.`" . $this->getDatabase()->getNamespace() . "_" . $collection . "`";
     $sql = "ALTER TABLE {$sqlTable} DROP COLUMN `{$column}`";
-
-    self::$pool->use(function (Adapter $adapter) use ($sql) {
-        // Hack to get adapter PDO reference
-        $class = new ReflectionClass($adapter);
-        $property = $class->getProperty('pdo');
-        $property->setAccessible(true);
-        $pdo = $property->getValue($adapter);
-        $pdo->exec($sql);
-    });
-
+    $this->getPDO()->exec($sql);
     return true;
 }

Apply the same pattern to deleteIndex and execRawSQL.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/PoolTest.php` around lines 82 - 92, Replace the duplicated
five-line reflection block in deleteColumn, deleteIndex, and execRawSQL with a
call to the existing getPDO() helper to obtain the PDO instance; specifically,
remove the ReflectionClass/->getProperty('pdo') logic from those methods and use
$this->getPDO() (or getPDO() if static context) to retrieve the PDO, then run
the existing SQL operations against that returned PDO object, leaving Adapter
and self::$pool usage only inside getPDO().
src/Database/Adapter/MariaDB.php (1)

2082-2090: IFNULL guards inconsistently removed — only APPEND/PREPEND cleaned up.

TYPE_ARRAY_REMOVE (line 2106), TYPE_ARRAY_UNIQUE (2113), TYPE_ARRAY_INTERSECT (2121), TYPE_ARRAY_DIFF (2133), and TYPE_ARRAY_FILTER (2147) all still wrap {$quotedColumn} with IFNULL(..., JSON_ARRAY()), while APPEND/PREPEND no longer do. Since the column is now NOT NULL, all these IFNULL guards are dead code. Consider removing them in the same pass for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/MariaDB.php` around lines 2082 - 2090, The ARRAY
operator handlers are inconsistent: APPEND/PREPEND now use {$quotedColumn}
directly but TYPE_ARRAY_REMOVE, TYPE_ARRAY_UNIQUE, TYPE_ARRAY_INTERSECT,
TYPE_ARRAY_DIFF and TYPE_ARRAY_FILTER still wrap {$quotedColumn} with
IFNULL(..., JSON_ARRAY()); remove those dead IFNULL wrappers so each case uses
{$quotedColumn} directly (keep the existing bind key logic and JSON functions
for each case), updating the branches for Operator::TYPE_ARRAY_REMOVE,
Operator::TYPE_ARRAY_UNIQUE, Operator::TYPE_ARRAY_INTERSECT,
Operator::TYPE_ARRAY_DIFF and Operator::TYPE_ARRAY_FILTER to match the
APPEND/PREPEND style.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Adapter/MariaDB.php`:
- Around line 1686-1688: The ALTER path that emits "JSON NOT NULL DEFAULT
(JSON_ARRAY())" will fail when existing rows have NULLs — modify the MariaDB
adapter's updateAttribute() flow to run an UPDATE table SET column =
JSON_ARRAY() WHERE column IS NULL (or equivalent) before executing ALTER TABLE
MODIFY/CHANGE using getSQLType() so NULLs are backfilled; also restore
consistent IFNULL() guarding for array operators by wrapping TYPE_ARRAY_APPEND
and TYPE_ARRAY_PREPEND expressions with IFNULL(column, JSON_ARRAY()) to match
the other operators (e.g., TYPE_ARRAY_REMOVE/UNIQUE/INTERSECT/DIFF/FILTER) so
behavior is uniform and safe.

In `@src/Database/Database.php`:
- Around line 8202-8208: The code currently coerces null to [] for optional
array attributes (when $value and $default are null) and unconditionally calls
$document->setAttribute($key, []) which causes optional arrays to be cleared
during partial/bulk updates (e.g., when encode(..., applyDefaults: false)).
Change the condition to only coerce when defaults should be applied or the
attribute was explicitly provided: add a check for the $applyDefaults flag (or
the presence of the attribute in the incoming payload) alongside $array &&
!$required before calling $document->setAttribute($key, []); keep the existing
variables ($value, $default, $attribute, $array, $required, $applyDefaults) and
the document->setAttribute($key, []) call but wrap it so it only runs when
applying defaults or when the attribute was explicitly set.

In `@tests/e2e/Adapter/Scopes/IndexTests.php`:
- Around line 1156-1164: The PHPStan error arises because $stmt->fetchColumn()
can return int|string|false and assertNotFalse() doesn't narrow it to string for
subsequent strtolower(); update the block around fetchColumn() and assertions to
keep the assertNotFalse($indexDef, ...) check on the raw $indexDef, then cast to
string before calling strtolower (e.g. $indexDefStr = (string) $indexDef) and
pass $indexDefStr to strtolower in the assertStringContainsString call;
reference symbols: $stmt->fetchColumn(), $indexDef, assertNotFalse, strtolower,
assertStringContainsString.

---

Outside diff comments:
In `@src/Database/Adapter/Postgres.php`:
- Around line 576-626: The SQL returned by getSQLType(...) can include modifiers
(e.g., "JSONB NOT NULL DEFAULT '[]'::jsonb") which break the ALTER COLUMN ...
TYPE statement in updateAttribute; modify updateAttribute to parse the string
returned by getSQLType($type, $size, $signed, $array, $required) into a base SQL
type token (e.g., "JSONB") and separate constraint pieces (NOT NULL and DEFAULT
expressions), then execute ALTER TABLE ... ALTER COLUMN "col" TYPE <baseType>
first, and afterwards apply constraints with separate ALTER COLUMN subcommands:
if DEFAULT present run ALTER COLUMN ... SET DEFAULT <expr>, if NOT NULL present
first run an UPDATE <table> SET "col" = <defaultExpr> WHERE "col" IS NULL to
backfill NULLs (when needed, e.g., JSONB default '[]'::jsonb), then run ALTER
COLUMN ... SET NOT NULL; reference updateAttribute and getSQLType to find where
to split the type string and apply these ordered ALTER statements.

---

Nitpick comments:
In `@src/Database/Adapter/MariaDB.php`:
- Around line 2082-2090: The ARRAY operator handlers are inconsistent:
APPEND/PREPEND now use {$quotedColumn} directly but TYPE_ARRAY_REMOVE,
TYPE_ARRAY_UNIQUE, TYPE_ARRAY_INTERSECT, TYPE_ARRAY_DIFF and TYPE_ARRAY_FILTER
still wrap {$quotedColumn} with IFNULL(..., JSON_ARRAY()); remove those dead
IFNULL wrappers so each case uses {$quotedColumn} directly (keep the existing
bind key logic and JSON functions for each case), updating the branches for
Operator::TYPE_ARRAY_REMOVE, Operator::TYPE_ARRAY_UNIQUE,
Operator::TYPE_ARRAY_INTERSECT, Operator::TYPE_ARRAY_DIFF and
Operator::TYPE_ARRAY_FILTER to match the APPEND/PREPEND style.

In `@tests/e2e/Adapter/PoolTest.php`:
- Around line 82-92: Replace the duplicated five-line reflection block in
deleteColumn, deleteIndex, and execRawSQL with a call to the existing getPDO()
helper to obtain the PDO instance; specifically, remove the
ReflectionClass/->getProperty('pdo') logic from those methods and use
$this->getPDO() (or getPDO() if static context) to retrieve the PDO, then run
the existing SQL operations against that returned PDO object, leaving Adapter
and self::$pool usage only inside getPDO().

Comment on lines 1686 to 1688
if ($array === true) {
return 'JSON';
return 'JSON NOT NULL DEFAULT (JSON_ARRAY())';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the updateAttribute method in MariaDB.php
head -n 450 src/Database/Adapter/MariaDB.php | tail -n 80

Repository: utopia-php/database

Length of output: 2845


🏁 Script executed:

# Check the full context of getSQLType for array handling
rg -n "getSQLType" src/Database/Adapter/MariaDB.php -A 3 -B 3

Repository: utopia-php/database

Length of output: 1486


🏁 Script executed:

# Look for any UPDATE or backfill logic related to array columns
rg -n "JSON_ARRAY\(\)" src/Database/Adapter/MariaDB.php -B 5 -A 5

Repository: utopia-php/database

Length of output: 3009


🏁 Script executed:

# Check if there are any migration or backfill mechanisms in Database.php or SQL.php
rg -n "updateAttribute" src/Database/ -A 5 -B 2 --type=php

Repository: utopia-php/database

Length of output: 16758


🏁 Script executed:

# Check for NULL backfill patterns in the codebase
rg -n "IS NULL.*JSON_ARRAY\|JSON_ARRAY.*IS NULL" src/Database/ --type=php

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Get the full updateAttribute method in Database.php around line 2616-2920
sed -n '2616,2935p' src/Database/Database.php | cat -n

Repository: utopia-php/database

Length of output: 16616


🏁 Script executed:

# Specifically look for any UPDATE statements in the updateAttribute flow
rg -n "UPDATE" src/Database/Database.php | grep -A 5 -B 5 "2616\|2700\|2800\|2900"

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check for any JSON_ARRAY backfill patterns in the entire Database.php
rg -n "JSON_ARRAY" src/Database/Database.php -B 5 -A 5

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for migration files or migration strategy
find . -type f -name "*migration*" -o -name "*migrate*" | head -20

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check if there are tests for updateAttribute with arrays
rg -n "updateAttribute.*array|array.*updateAttribute" tests/ --type=php -B 3 -A 3

Repository: utopia-php/database

Length of output: 707


🏁 Script executed:

# Look for any test that validates updating array attributes on existing tables
rg -n "testUpdateAttribute" tests/ --type=php -l

Repository: utopia-php/database

Length of output: 279


🏁 Script executed:

# Check what the test file mentioned in learnings says about array attributes
sed -n '6418,6439p' tests/e2e/Adapter/Scopes/DocumentTests.php

Repository: utopia-php/database

Length of output: 1068


🏁 Script executed:

# Examine the AttributeTests.php for array attribute tests
rg -n "updateAttribute" tests/e2e/Adapter/Scopes/AttributeTests.php -B 5 -A 10 | head -100

Repository: utopia-php/database

Length of output: 4513


🏁 Script executed:

# Look for tests that create data with NULL or then update attribute to NOT NULL
rg -n "createDocument\|updateAttribute" tests/e2e/Adapter/Scopes/AttributeTests.php -B 2 -A 2 | head -80

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check if getSQLType array behavior is consistent across adapters
rg -n "array === true" src/Database/Adapter/ --type=php -B 2 -A 2

Repository: utopia-php/database

Length of output: 860


🏁 Script executed:

# Look for any schemaless adapter handling
rg -n "getSupportForAttributes\|schemaless" src/Database/Adapter/ --type=php -B 3 -A 3 | head -80

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check if there's a specific test for updating array attributes on existing collections
rg -n "array.*true|createAttribute.*array" tests/e2e/Adapter/Scopes/AttributeTests.php -B 3 -A 8

Repository: utopia-php/database

Length of output: 3855


🏁 Script executed:

# Check what createAttribute does for array types
rg -n "createAttribute" src/Database/Adapter/MariaDB.php -A 30 | head -60

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look at parent SQL adapter for array handling
rg -n "array === true|JSON NOT NULL" src/Database/Adapter/SQL.php -B 5 -A 5

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check if there's a difference between initial creation and update for NOT NULL handling
git log --oneline -20 -- src/Database/Adapter/MariaDB.php

Repository: utopia-php/database

Length of output: 159


🏁 Script executed:

# Look at the recent commit that mentions NOT NULL arrays
git show 4f02d63f --stat | head -40

Repository: utopia-php/database

Length of output: 2426


🏁 Script executed:

# Check the parent class SQL.php to see if createAttribute creates with NOT NULL
rg -n "createAttribute\|getSQLType" src/Database/Adapter/SQL.php -A 15 | head -100

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for the createAttribute implementation in MariaDB or parent
rg -n "public function createAttribute" src/Database/Adapter/ -A 20 | head -80

Repository: utopia-php/database

Length of output: 5879


🏁 Script executed:

# Check if there's any test that actually calls updateAttribute on array fields
rg -n "updateAttribute.*array.*true|array.*true.*updateAttribute" tests/ -B 5 -A 10

Repository: utopia-php/database

Length of output: 1608


🏁 Script executed:

# Verify when createAttribute is used vs updateAttribute for array fields
rg -n "createAttribute|updateAttribute" tests/e2e/Adapter/Scopes/AttributeTests.php | grep -i array

Repository: utopia-php/database

Length of output: 364


🏁 Script executed:

# Check if Postgres adapter has the same issue
cat src/Database/Adapter/Postgres.php | grep -A 5 "array === true"

Repository: utopia-php/database

Length of output: 221


🏁 Script executed:

# Check if there's a test that specifically tests updating an existing array column 
# that might have NULL values (e.g., converting non-array to array)
rg -n "createDocument\|createAttribute" tests/e2e/Adapter/Scopes/AttributeTests.php -B 2 -A 5 | grep -A 20 "non-array\|convert"

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for any migration or update scenario in tests where data exists before updateAttribute
rg -n "createDocument.*\$|createDocument" tests/e2e/Adapter/Scopes/AttributeTests.php | head -20

Repository: utopia-php/database

Length of output: 1508


🏁 Script executed:

# Specifically check if there's a test scenario where documents are created, 
# then an attribute is changed to array=true
rg -n "array.*true" tests/e2e/Adapter/Scopes/AttributeTests.php -B 20 | grep -A 20 "createDocument"

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check if there's validation preventing changing array from false to true
rg -n "originalArray\|array.*change\|array.*update" src/Database/Database.php -B 3 -A 3

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for what happens when originalArray != array (is this even allowed?)
sed -n '2616,2650p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 1706


🏁 Script executed:

# Check if there's a scenario in tests where array is changed
rg -n "originalArray" src/Database/ --type=php -B 5 -A 5

Repository: utopia-php/database

Length of output: 1632


🏁 Script executed:

# Check TYPE_ARRAY_APPEND and TYPE_ARRAY_PREPEND implementations
rg -n "TYPE_ARRAY_APPEND|TYPE_ARRAY_PREPEND" src/Database/Adapter/MariaDB.php -B 2 -A 15

Repository: utopia-php/database

Length of output: 1066


🏁 Script executed:

# Verify the issue: check if array attribute can be changed from false to true on existing table
rg -n "array.*false\|array.*true" tests/e2e/Adapter/Scopes/AttributeTests.php -B 5 -A 5 | head -50

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check if there's any constraint preventing changing array=false to array=true
rg -n "array.*originalArray\|changing.*array\|convert.*array" src/Database/ --type=php -B 3 -A 3

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Final check: look for validation that prevents this specific change
sed -n '2642,2750p' src/Database/Database.php | cat -n

Repository: utopia-php/database

Length of output: 5648


🏁 Script executed:

# Confirm: check if there are other operators that still have IFNULL
rg -n "TYPE_ARRAY_UNIQUE|TYPE_ARRAY_REMOVE|TYPE_ARRAY_INTERSECT|TYPE_ARRAY_DIFF|TYPE_ARRAY_FILTER" src/Database/Adapter/MariaDB.php -A 8 | grep -E "IFNULL|TYPE_ARRAY"

Repository: utopia-php/database

Length of output: 598


🏁 Script executed:

# Final verification: confirm IFNULL was removed from APPEND/PREPEND but kept elsewhere
git show HEAD:src/Database/Adapter/MariaDB.php | grep -A 5 "TYPE_ARRAY_APPEND\|TYPE_ARRAY_PREPEND" | head -20

Repository: utopia-php/database

Length of output: 539


MODIFY/CHANGE COLUMN … NOT NULL will fail for existing tables with any NULL array values.

updateAttribute() (Database.php, line 2915) passes getSQLType() output directly to the adapter's updateAttribute() method, which executes ALTER TABLE … MODIFY/CHANGE COLUMN with no preceding UPDATE to backfill NULLs. Any production collection with existing NULL values in an array column will cause the ALTER TABLE to fail with a data-integrity error when this code path is triggered.

The PHP-side null-to-[] coercion only governs future writes and does not retroactively fix existing DB NULLs.

A safe approach is to issue an UPDATE to coerce NULLs to JSON_ARRAY() before the ALTER TABLE:

🛡️ Proposed fix: backfill NULLs before MODIFY
 public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool
 {
     $name = $this->filter($collection);
     $id = $this->filter($id);
     $newKey = empty($newKey) ? null : $this->filter($newKey);
     $type = $this->getSQLType($type, $size, $signed, $array, $required);
+
+    // Backfill NULLs before adding NOT NULL constraint on array columns
+    if ($array) {
+        $backfill = "UPDATE {$this->getSQLTable($name)} SET `{$id}` = JSON_ARRAY() WHERE `{$id}` IS NULL";
+        $this->getPDO()->prepare($backfill)->execute();
+    }
+
     if (!empty($newKey)) {
         $sql = "ALTER TABLE {$this->getSQLTable($name)} CHANGE COLUMN `{$id}` `{$newKey}` {$type};";
     } else {
         $sql = "ALTER TABLE {$this->getSQLTable($name)} MODIFY `{$id}` {$type};";
     }

Additionally, TYPE_ARRAY_APPEND and TYPE_ARRAY_PREPEND (lines 2085, 2090) dropped their IFNULL guards, while TYPE_ARRAY_REMOVE, TYPE_ARRAY_UNIQUE, TYPE_ARRAY_INTERSECT, TYPE_ARRAY_DIFF, and TYPE_ARRAY_FILTER (lines 2103–2161) retain them. This asymmetry is harmless with the NOT NULL constraint but creates inconsistency that will mislead future maintainers. Consider applying IFNULL consistently across all array operators for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/MariaDB.php` around lines 1686 - 1688, The ALTER path
that emits "JSON NOT NULL DEFAULT (JSON_ARRAY())" will fail when existing rows
have NULLs — modify the MariaDB adapter's updateAttribute() flow to run an
UPDATE table SET column = JSON_ARRAY() WHERE column IS NULL (or equivalent)
before executing ALTER TABLE MODIFY/CHANGE using getSQLType() so NULLs are
backfilled; also restore consistent IFNULL() guarding for array operators by
wrapping TYPE_ARRAY_APPEND and TYPE_ARRAY_PREPEND expressions with
IFNULL(column, JSON_ARRAY()) to match the other operators (e.g.,
TYPE_ARRAY_REMOVE/UNIQUE/INTERSECT/DIFF/FILTER) so behavior is uniform and safe.

Comment on lines 1156 to 1164
// 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');
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if file exists and get line count
wc -l tests/e2e/Adapter/Scopes/IndexTests.php

# View the exact lines mentioned in the review comment
sed -n '1150,1170p' tests/e2e/Adapter/Scopes/IndexTests.php

Repository: utopia-php/database

Length of output: 1098


🏁 Script executed:

#!/bin/bash
# Search for fetchColumn and strtolower usage in the file
rg -n "fetchColumn" tests/e2e/Adapter/Scopes/IndexTests.php -B2 -A5

Repository: utopia-php/database

Length of output: 516


🏁 Script executed:

#!/bin/bash
# Check for similar fetchColumn patterns in the codebase to understand typical handling
rg "fetchColumn\(\)" tests/e2e/Adapter/Scopes/ -B2 -A2

# Check if there are other places where strtolower is used with fetchColumn results
rg "fetchColumn.*strtolower|strtolower.*fetchColumn" tests/e2e/Adapter/Scopes/ -B3 -A3

Repository: utopia-php/database

Length of output: 604


🏁 Script executed:

#!/bin/bash
# Check the PDO documentation by looking at type hints or casts in the codebase
rg "fetchColumn" src/ -B1 -A1 | head -50

Repository: utopia-php/database

Length of output: 2084


🏁 Script executed:

#!/bin/bash
# Check if there are other similar fetchColumn patterns in IndexTests.php that might have same issue
rg "fetchColumn\(\)" tests/e2e/Adapter/Scopes/IndexTests.php -n

Repository: utopia-php/database

Length of output: 113


Fix PHPStan type error by casting fetchColumn() result to string.

fetchColumn() returns int|string|false, and assertNotFalse() does not narrow the type to string in PHPStan's analysis. Cast the result to string before passing it to strtolower():

🛠️ Proposed fix
             $indexDef = $stmt->fetchColumn();
             $this->assertNotFalse($indexDef, 'GIN index should exist for array column');
+            $indexDef = (string) $indexDef;
             $this->assertStringContainsString('gin', \strtolower($indexDef), 'Index on array column should be a GIN index');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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');
} else {
// 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');
$indexDef = (string) $indexDef;
$this->assertStringContainsString('gin', \strtolower($indexDef), 'Index on array column should be a GIN index');
} else {
🧰 Tools
🪛 GitHub Actions: CodeQL

[error] 1163-1163: PHPStan error: Parameter #1 $string of function strtolower expects string, int|string|null given. Command: ./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/IndexTests.php` around lines 1156 - 1164, The
PHPStan error arises because $stmt->fetchColumn() can return int|string|false
and assertNotFalse() doesn't narrow it to string for subsequent strtolower();
update the block around fetchColumn() and assertions to keep the
assertNotFalse($indexDef, ...) check on the raw $indexDef, then cast to string
before calling strtolower (e.g. $indexDefStr = (string) $indexDef) and pass
$indexDefStr to strtolower in the assertStringContainsString call; reference
symbols: $stmt->fetchColumn(), $indexDef, assertNotFalse, strtolower,
assertStringContainsString.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/Database/Adapter/Postgres.php (2)

2718-2727: Removing COALESCE is correct for NOT NULL columns, but note the migration risk.

Dropping COALESCE(col, '[]'::jsonb) is safe once all rows have been backfilled to non-null. However, for any existing row where the column is still NULL (e.g., before a backfill migration runs), NULL::jsonb || :op::jsonb evaluates to NULL in PostgreSQL — the append/prepend silently becomes a no-op instead of raising an error.

Ensure the schema migration and data backfill (UPDATE … SET col = '[]'::jsonb WHERE col IS NULL) run before this code is deployed, or consider temporarily keeping the COALESCE until migration is confirmed complete.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Postgres.php` around lines 2718 - 2727, The change
removing COALESCE from the array append/prepend SQL (cases for
Operator::TYPE_ARRAY_APPEND and Operator::TYPE_ARRAY_PREPEND that return
"{$quotedColumn} = {$columnRef} || :$bindKey::jsonb" and "{$quotedColumn} =
:$bindKey::jsonb || {$columnRef}") can silently no-op if existing rows contain
NULL; ensure you either run the migration/backfill (UPDATE … SET col =
'[]'::jsonb WHERE col IS NULL) before deploying this code or revert to using
COALESCE(columnRef, '[]'::jsonb) in these branches (or both) so that
quotedColumn/columnRef operations behave correctly until all rows are non-null.

895-913: Extra DB round-trip per INDEX_KEY index creation.

createCollection loops over indexes and calls createIndex for each; every INDEX_KEY index now incurs an additional getDocument call to fetch collection metadata. For collections with many array indexes, this multiplies the number of DB round-trips.

Consider enriching the existing $indexAttributeTypes parameter (or adding an $indexAttributeFlags parallel map) with the array flag so callers can pass this information in, eliminating the query entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Postgres.php` around lines 895 - 913, createIndex
currently does an extra DB round-trip by calling getDocument on
Database::METADATA to detect array-typed attributes when handling
Database::INDEX_KEY; to fix this, add the attribute "array" flag to the existing
$indexAttributeTypes parameter (or create a parallel $indexAttributeFlags map)
at the call site (e.g., where createCollection assembles index metadata) and
change createIndex in Postgres.php to read the array flag from that parameter
instead of constructing a metadata Document and calling getDocument; update
createCollection to pass the new array-aware map when iterating indexes so
createIndex can compute $isArrayIndex from the provided flags and remove the
whole getDocument/collectionAttributes loop.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Adapter/Postgres.php`:
- Around line 1957-1961: getSQLType currently returns "JSONB NOT NULL DEFAULT
'[]'::jsonb" for array columns which is invalid inside an ALTER COLUMN ... TYPE
clause; change getSQLType(array=true) to return only "JSONB" and update
updateAttribute to emit separate ALTER COLUMN subcommands for NOT NULL and
DEFAULT (e.g., ALTER TABLE ... ALTER COLUMN "col" TYPE JSONB, ALTER TABLE ...
ALTER COLUMN "col" SET DEFAULT '[]'::jsonb, and ALTER TABLE ... ALTER COLUMN
"col" SET NOT NULL / DROP NOT NULL as needed). Adjust logic in updateAttribute
to detect array columns (or call to getSQLType) and append separate ALTER
clauses for defaults and nullability rather than embedding them in the TYPE
string.

In `@src/Database/Adapter/SQL.php`:
- Around line 2683-2686: createDocuments is missing the null-to-'[]' coercion
for array-typed attributes which causes NULL to be bound for NOT NULL DEFAULT
'[]' columns; mirror the fix in upsertDocuments by building the $arrayAttributes
map just before the loop that iterates over documents (same approach used in
upsertDocuments) and, in the value-binding loop inside createDocuments where you
currently use $attributes[$key] ?? null, coerce $attrValue === null &&
isset($arrayAttributes[$key]) to '[]' before binding; reference createDocuments,
upsertDocuments, $attributeKeys, $attributes, $arrayAttributes and the binding
loop when applying the change.

---

Nitpick comments:
In `@src/Database/Adapter/Postgres.php`:
- Around line 2718-2727: The change removing COALESCE from the array
append/prepend SQL (cases for Operator::TYPE_ARRAY_APPEND and
Operator::TYPE_ARRAY_PREPEND that return "{$quotedColumn} = {$columnRef} ||
:$bindKey::jsonb" and "{$quotedColumn} = :$bindKey::jsonb || {$columnRef}") can
silently no-op if existing rows contain NULL; ensure you either run the
migration/backfill (UPDATE … SET col = '[]'::jsonb WHERE col IS NULL) before
deploying this code or revert to using COALESCE(columnRef, '[]'::jsonb) in these
branches (or both) so that quotedColumn/columnRef operations behave correctly
until all rows are non-null.
- Around line 895-913: createIndex currently does an extra DB round-trip by
calling getDocument on Database::METADATA to detect array-typed attributes when
handling Database::INDEX_KEY; to fix this, add the attribute "array" flag to the
existing $indexAttributeTypes parameter (or create a parallel
$indexAttributeFlags map) at the call site (e.g., where createCollection
assembles index metadata) and change createIndex in Postgres.php to read the
array flag from that parameter instead of constructing a metadata Document and
calling getDocument; update createCollection to pass the new array-aware map
when iterating indexes so createIndex can compute $isArrayIndex from the
provided flags and remove the whole getDocument/collectionAttributes loop.

Comment on lines 1957 to 1961
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";
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Search for the getSQLType method implementation to confirm the behavior with arrays
rg -A 30 "protected function getSQLType" src/Database/Adapter/Postgres.php

Repository: utopia-php/database

Length of output: 1197


🏁 Script executed:

# Search for updateAttribute method to see how it uses getSQLType
rg -A 50 "protected function updateAttribute" src/Database/Adapter/Postgres.php | head -80

Repository: utopia-php/database

Length of output: 45


🌐 Web query:

PostgreSQL ALTER TABLE ALTER COLUMN TYPE syntax - does it support NOT NULL DEFAULT modifiers

💡 Result:

ALTER TABLE ... ALTER COLUMN ... TYPE (aka SET DATA TYPE) does not accept inline column-definition modifiers like NOT NULL or DEFAULT. It only changes the data type (optionally with USING/COLLATE). [1][2]

To change those properties, use separate subcommands:

ALTER TABLE t
  ALTER COLUMN c TYPE new_type USING ...,
  ALTER COLUMN c SET DEFAULT new_default,
  ALTER COLUMN c SET NOT NULL;

PostgreSQL will also try to cast the existing column default (if any) and constraints that reference the column when you change the type, which is why doing SET DEFAULT explicitly after a type change is sometimes clearer/safer. [1]

Sources:
[1] PostgreSQL docs (current): “Modifying Tables” (ALTER COLUMN TYPE / SET DEFAULT / SET NOT NULL) (postgresql.org)
[2] PostgreSQL ALTER TABLE reference (Postgres Pro mirror): SET DATA TYPE, SET/DROP DEFAULT, SET/DROP NOT NULL (postgrespro.com)

Citations:


🏁 Script executed:

# Search for updateAttribute method with more context
rg -A 100 "protected function updateAttribute" src/Database/Adapter/Postgres.php | head -120

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Also search for where getSQLType is called within updateAttribute context
rg -B 5 -A 15 "getSQLType.*\$array" src/Database/Adapter/Postgres.php

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Search for ALTER COLUMN in Postgres.php to find where updateAttribute uses the type
rg -B 10 "ALTER COLUMN" src/Database/Adapter/Postgres.php | head -100

Repository: utopia-php/database

Length of output: 299


🏁 Script executed:

# Try to find updateAttribute by searching for the method name more broadly
rg "updateAttribute" src/Database/Adapter/Postgres.php

Repository: utopia-php/database

Length of output: 251


🏁 Script executed:

# Get the full updateAttribute implementation
rg -A 150 "public function updateAttribute" src/Database/Adapter/Postgres.php | head -200

Repository: utopia-php/database

Length of output: 5027


updateAttribute will generate invalid SQL for array attributes.

getSQLType now returns "JSONB NOT NULL DEFAULT '[]'::jsonb" when $array === true. This string is fed directly into the ALTER COLUMN TYPE clause:

ALTER TABLE schema.table ALTER COLUMN "col" TYPE JSONB NOT NULL DEFAULT '[]'::jsonb

PostgreSQL's ALTER COLUMN TYPE grammar accepts only data_type [COLLATE …] [USING …]. The NOT NULL and DEFAULT clauses must be issued as separate ALTER COLUMN sub-commands; their presence in the TYPE clause causes a syntax error.

This is a regression: the previous return value (JSONB alone) was valid for the TYPE clause.

Proposed fix in updateAttribute
         $type = $this->getSQLType(
             $type,
             $size,
             $signed,
             $array,
             $required,
         );

+        // For ALTER COLUMN TYPE the NOT NULL / DEFAULT modifiers must be issued
+        // as separate ALTER COLUMN sub-commands; strip them from the type string.
+        $alterType = preg_replace('/\s+NOT NULL.*$/i', '', $type);
+
         if ($type == 'TIMESTAMP(3)') {
-            $type = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')";
+            $alterType = "TIMESTAMP(3) without time zone USING TO_TIMESTAMP(\"$id\", 'YYYY-MM-DD HH24:MI:SS.MS')";
         }

         // ... (use $alterType in the ALTER COLUMN sql, keep $type for other uses)
         $sql = "
             ALTER TABLE {$this->getSQLTable($name)}
-            ALTER COLUMN \"{$id}\" TYPE {$type}
+            ALTER COLUMN \"{$id}\" TYPE {$alterType}
         ";
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 1957-1957: Avoid unused parameters such as '$signed'. (undefined)

(UnusedFormalParameter)


[warning] 1957-1957: Avoid unused parameters such as '$required'. (undefined)

(UnusedFormalParameter)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Postgres.php` around lines 1957 - 1961, getSQLType
currently returns "JSONB NOT NULL DEFAULT '[]'::jsonb" for array columns which
is invalid inside an ALTER COLUMN ... TYPE clause; change getSQLType(array=true)
to return only "JSONB" and update updateAttribute to emit separate ALTER COLUMN
subcommands for NOT NULL and DEFAULT (e.g., ALTER TABLE ... ALTER COLUMN "col"
TYPE JSONB, ALTER TABLE ... ALTER COLUMN "col" SET DEFAULT '[]'::jsonb, and
ALTER TABLE ... ALTER COLUMN "col" SET NOT NULL / DROP NOT NULL as needed).
Adjust logic in updateAttribute to detect array columns (or call to getSQLType)
and append separate ALTER clauses for defaults and nullability rather than
embedding them in the TYPE string.

Comment on lines +2683 to +2686
// Array columns are NOT NULL DEFAULT '[]', so coerce null to empty array
if ($attrValue === null && isset($arrayAttributes[$attributeKey])) {
$attrValue = '[]';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the file structure and locate the relevant methods
fd "SQL.php" --type f | head -5

Repository: utopia-php/database

Length of output: 122


🏁 Script executed:

# Get line count for SQL.php to understand the file size
wc -l src/Database/Adapter/SQL.php

Repository: utopia-php/database

Length of output: 96


🏁 Script executed:

# Check the createDocuments method around line 2451-2586
sed -n '2451,2500p' src/Database/Adapter/SQL.php | cat -n

Repository: utopia-php/database

Length of output: 2189


🏁 Script executed:

# Check the $arrayAttributes collection and coercion in upsertDocuments (lines 2607-2686)
sed -n '2607,2700p' src/Database/Adapter/SQL.php | cat -n

Repository: utopia-php/database

Length of output: 4999


🏁 Script executed:

# Check the second coercion block mentioned (lines 2815-2818)
sed -n '2800,2830p' src/Database/Adapter/SQL.php | cat -n

Repository: utopia-php/database

Length of output: 1895


🏁 Script executed:

# Search for encode method in Database.php to check for upstream coercion
rg -n "public function encode|private function encode" src/Database/Database.php -A 15 | head -80

Repository: utopia-php/database

Length of output: 899


🏁 Script executed:

# See the rest of createDocuments to find the value binding logic
sed -n '2500,2586p' src/Database/Adapter/SQL.php | cat -n

Repository: utopia-php/database

Length of output: 4231


🏁 Script executed:

# See the full Database::encode() method to check for array coercion
sed -n '8177,8250p' src/Database/Database.php | cat -n

Repository: utopia-php/database

Length of output: 3322


🏁 Script executed:

# Verify that createDocuments is called in context where encode() may not be applied first
rg -n "createDocuments" src/Database/Database.php -B 3 -A 3 | head -50

Repository: utopia-php/database

Length of output: 662


🏁 Script executed:

# Check if Database::createDocuments calls encode() before adapter->createDocuments()
sed -n '5118,5200p' src/Database/Database.php | cat -n

Repository: utopia-php/database

Length of output: 4233


createDocuments is missing the same null-to-'[]' guard added here.

upsertDocuments correctly coerces null'[]' for array-typed attributes (lines 2683–2686 and 2815–2818). However, createDocuments builds $attributeKeys as the union of all documents in the batch, then falls back to $attributes[$key] ?? null for each key. When document A has an optional array attribute and document B does not, document B binds NULL for a NOT NULL DEFAULT '[]' column → constraint violation.

🛡️ Proposed fix in createDocuments (SQL.php ~line 2456)

Collect the $arrayAttributes map the same way as upsertDocuments does, just before the loop that iterates over documents:

     $spatialAttributes = $this->getSpatialAttributes($collection);
     $collection = $collection->getId();
+
+    $arrayAttributes = [];
+    foreach ($collection->getAttribute('attributes', []) as $attr) {
+        if ($attr['array'] ?? false) {
+            $arrayAttributes[$attr['$id']] = true;
+        }
+    }

Then apply the coercion when binding values:

     foreach ($attributeKeys as $key) {
         $value = $attributes[$key] ?? null;
+        // Array columns are NOT NULL DEFAULT '[]', so coerce null to empty array
+        if ($value === null && isset($arrayAttributes[$key])) {
+            $value = '[]';
+        }
         if (\is_array($value)) {
             $value = \json_encode($value);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/SQL.php` around lines 2683 - 2686, createDocuments is
missing the null-to-'[]' coercion for array-typed attributes which causes NULL
to be bound for NOT NULL DEFAULT '[]' columns; mirror the fix in upsertDocuments
by building the $arrayAttributes map just before the loop that iterates over
documents (same approach used in upsertDocuments) and, in the value-binding loop
inside createDocuments where you currently use $attributes[$key] ?? null, coerce
$attrValue === null && isset($arrayAttributes[$key]) to '[]' before binding;
reference createDocuments, upsertDocuments, $attributeKeys, $attributes,
$arrayAttributes and the binding loop when applying the change.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/e2e/Adapter/Scopes/IndexTests.php (2)

1064-1109: Missing try/finally — collection leaks on assertion failure.

Both new tests call deleteCollection unconditionally at the end. If any assertion fires before that line, the collection is never cleaned up, which can cause spurious failures in subsequent test runs. Every other cleanup-bearing test in this file wraps the body in try { … } finally { $database->deleteCollection(…); }.

🛠️ Proposed fix for `testArrayAttributeNotNullSchema`
         $database->createAttribute($collection, 'tags', Database::VAR_STRING, 255, false, array: true);

+        try {
         // Verify the physical column is NOT NULL via INFORMATION_SCHEMA
         $pdo = $this->getPDO();
         // ... (rest of the test body)
         $this->assertEquals('NO', $col['IS_NULLABLE'], /* ... */);
+        } finally {
-        $database->deleteCollection($collection);
+            $database->deleteCollection($collection);
+        }
     }

Apply the same pattern to testArrayIndexUsedInQuery (wrap everything after createCollection in try { … } finally { $database->deleteCollection($collection); }).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/IndexTests.php` around lines 1064 - 1109, Wrap the
post-createCollection test bodies in a try { … } finally {
$database->deleteCollection($collection); } to guarantee cleanup on assertion
failures: in testArrayAttributeNotNullSchema and testArrayIndexUsedInQuery, move
all code after $database->createCollection(...) into a try block and place the
$database->deleteCollection($collection) call inside the finally block so the
collection is always removed even if assertions fail.

1091-1106: COLUMN_DEFAULT is fetched but never asserted.

The SELECT retrieves both IS_NULLABLE and COLUMN_DEFAULT, but only IS_NULLABLE is checked. The stated motivation (MySQL optimizer bug #111037 and JSON_CONTAINS(NULL, …) returning NULL) requires both NOT NULL and a default empty-array. Asserting the default would catch adapters that set NOT NULL without a proper default, leaving inserts without the tags field to fail.

💡 Suggested addition
         $this->assertEquals('NO', $col['IS_NULLABLE'], /* ... */);
+        $this->assertNotNull(
+            $col['COLUMN_DEFAULT'],
+            'Array (JSON) column must have a NOT NULL default (e.g. "[]" or "(json_array())")'
+        );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/IndexTests.php` around lines 1091 - 1106, The test
selects IS_NULLABLE and COLUMN_DEFAULT for the "tags" column but never asserts
the default; update the test around the $stmt/$col handling to also assert that
$col['COLUMN_DEFAULT'] equals the expected empty-array default (e.g. '[]' or the
DB-specific literal your adapter uses) so that adapters setting NOT NULL without
a default fail the test; locate the block using $stmt->execute([$db, $tableName,
'tags']) and $col = $stmt->fetch(...) and add an assertion checking
COLUMN_DEFAULT on $col immediately after the IS_NULLABLE assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/e2e/Adapter/Scopes/IndexTests.php`:
- Around line 1199-1204: The test fails on the Postgres/GIN path because
Database::find() applies a default limit (25), so when getSupportForGinIndex()
yields $total = 5000 and $expectedCount = 100 the result in $results is
truncated; update the query passed to Database::find() to include an explicit
limit by adding Query::limit($expectedCount) alongside
Query::containsAny('tags', ['tag_1']) so the find call returns the full
expectedCount before asserting $this->assertCount($expectedCount, $results).

---

Duplicate comments:
In `@tests/e2e/Adapter/Scopes/IndexTests.php`:
- Around line 1162-1164: PHPStan complains because $indexDef (from
$stmt->fetchColumn()) is typed mixed and assertNotFalse() doesn't narrow it to
string before calling strtolower; update the test in IndexTests.php to ensure
$indexDef is a string before using strtolower by adding an explicit assertion
like $this->assertIsString($indexDef, 'Index definition must be a string') or
alternatively cast $indexDef to string when calling strtolower in the
assertStringContainsString call; reference $stmt->fetchColumn(), $indexDef,
assertNotFalse(), assertIsString(), assertStringContainsString(), and
strtolower() so the change is applied to the same assertion block.

---

Nitpick comments:
In `@tests/e2e/Adapter/Scopes/IndexTests.php`:
- Around line 1064-1109: Wrap the post-createCollection test bodies in a try { …
} finally { $database->deleteCollection($collection); } to guarantee cleanup on
assertion failures: in testArrayAttributeNotNullSchema and
testArrayIndexUsedInQuery, move all code after $database->createCollection(...)
into a try block and place the $database->deleteCollection($collection) call
inside the finally block so the collection is always removed even if assertions
fail.
- Around line 1091-1106: The test selects IS_NULLABLE and COLUMN_DEFAULT for the
"tags" column but never asserts the default; update the test around the
$stmt/$col handling to also assert that $col['COLUMN_DEFAULT'] equals the
expected empty-array default (e.g. '[]' or the DB-specific literal your adapter
uses) so that adapters setting NOT NULL without a default fail the test; locate
the block using $stmt->execute([$db, $tableName, 'tags']) and $col =
$stmt->fetch(...) and add an assertion checking COLUMN_DEFAULT on $col
immediately after the IS_NULLABLE assertion.

Comment on lines +1199 to +1204
// 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the test file and surrounding context
cat -n tests/e2e/Adapter/Scopes/IndexTests.php | sed -n '1170,1210p'

Repository: utopia-php/database

Length of output: 1889


🏁 Script executed:

# Find the Database class and its find() method signature
fd "Database.php" --type f | grep -v node_modules | head -5

Repository: utopia-php/database

Length of output: 88


🏁 Script executed:

# Look for the default limit in Database class
rg -n "DEFAULT_LIMIT|limit.*=.*25|getLimitForQuery" --type php | head -20

Repository: utopia-php/database

Length of output: 1538


🏁 Script executed:

# Read broader test context to see where $total is set
cat -n tests/e2e/Adapter/Scopes/IndexTests.php | sed -n '1100,1210p'

Repository: utopia-php/database

Length of output: 5222


🏁 Script executed:

# Find the test method definition to understand the full scope
rg -n "function test.*\{|public function" tests/e2e/Adapter/Scopes/IndexTests.php | grep -B5 -A5 "1100\|1150\|1200"

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Verify that Query::limit() is available and works correctly
rg -n "class Query|public static function limit" --type php | head -20

Repository: utopia-php/database

Length of output: 374


🏁 Script executed:

# Check if there are any existing calls in the test file using Query::limit()
rg -n "Query::limit" tests/e2e/Adapter/Scopes/IndexTests.php

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check how Query::limit() is used in other parts of the codebase/tests
rg -n "find\(.*Query::limit" tests/ --type php -A2 -B2 | head -30

Repository: utopia-php/database

Length of output: 2482


find() default limit will cause assertCount to fail on the Postgres/GIN path.

When getSupportForGinIndex() is true, $total = 5000 and $expectedCount = 100, but Database::find() applies a default limit of 25. The assertion assertCount(100, $results) will fail because the result set is capped at 25 items.

The MySQL path (500 documents, $expectedCount = 10) is unaffected since 10 is within the default limit.

Add an explicit Query::limit($expectedCount) to override the default:

Proposed fix
         $results = $database->find($collection, [
             Query::containsAny('tags', ['tag_1']),
+            Query::limit($expectedCount),
         ]);
         $this->assertCount($expectedCount, $results);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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);
// 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']),
Query::limit($expectedCount),
]);
$this->assertCount($expectedCount, $results);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/IndexTests.php` around lines 1199 - 1204, The test
fails on the Postgres/GIN path because Database::find() applies a default limit
(25), so when getSupportForGinIndex() yields $total = 5000 and $expectedCount =
100 the result in $results is truncated; update the query passed to
Database::find() to include an explicit limit by adding
Query::limit($expectedCount) alongside Query::containsAny('tags', ['tag_1']) so
the find call returns the full expectedCount before asserting
$this->assertCount($expectedCount, $results).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant