3535 */
3636class SQLite extends MariaDB
3737{
38+ /**
39+ * MariaDB byte ceilings for TEXT-family types, mirrored so PRAGMA-based
40+ * introspection produces the same characterMaximumLength values that
41+ * INFORMATION_SCHEMA.COLUMNS would on MariaDB.
42+ */
43+ private const MARIADB_TEXT_BYTES = '65535 ' ;
44+ private const MARIADB_MEDIUMTEXT_BYTES = '16777215 ' ;
45+ private const MARIADB_LONGTEXT_BYTES = '4294967295 ' ;
46+
3847 /**
3948 * @inheritDoc
4049 */
@@ -250,12 +259,12 @@ public function getSizeOfCollection(string $collection): int
250259 // FTS5 virtual tables don't show up in dbstat themselves — their
251260 // storage is backed by shadow tables named `<vtable>_data`,
252261 // `<vtable>_idx`, `<vtable>_docsize`, and `<vtable>_config`. Match
253- // the prefix and let LIKE pull in all of them. Sum "payload" rather
254- // than "pgsize" so the result tracks actual stored bytes — page
255- // allocation is granular at 4KB , which would let small inserts
262+ // the prefix and let LIKE pull in all of them. Sum (pgsize - unused)
263+ // so the result tracks bytes actually used inside each page rather
264+ // than full 4KB page allocations , which would let small inserts
256265 // appear as a no-op against the parent assertions.
257266 $ stmt = $ this ->getPDO ()->prepare ("
258- SELECT COALESCE(SUM( \"payload \" ), 0) + COALESCE(SUM( \" pgsize \" - \"unused \" - \" payload \"), 0)
267+ SELECT COALESCE(SUM( \"pgsize \" - \"unused \"), 0)
259268 FROM \"dbstat \"
260269 WHERE name = :name OR name = :perms OR name LIKE :fts_pattern;
261270 " );
@@ -520,34 +529,45 @@ protected function createFulltextIndex(string $collection, string $id, array $at
520529 $ newColumnList = \implode (', ' , \array_map (fn (string $ c ) => "NEW.` {$ c }` " , $ columns ));
521530 $ oldColumnList = \implode (', ' , \array_map (fn (string $ c ) => "OLD.` {$ c }` " , $ columns ));
522531
523- $ createSql = "CREATE VIRTUAL TABLE ` {$ ftsTable }` USING fts5( {$ columnList }, content=` {$ parentTable }`, content_rowid=`_id`) " ;
524- $ createSql = $ this ->trigger (Database::EVENT_INDEX_CREATE , $ createSql );
525- $ this ->getPDO ()->prepare ($ createSql )->execute ();
526-
527- $ insertTrigger = "
528- CREATE TRIGGER ` {$ ftsTable }_ai` AFTER INSERT ON ` {$ parentTable }` BEGIN
529- INSERT INTO ` {$ ftsTable }` (rowid, {$ columnList }) VALUES (NEW.`_id`, {$ newColumnList });
530- END
531- " ;
532- $ this ->getPDO ()->prepare ($ insertTrigger )->execute ();
533-
534- $ deleteTrigger = "
535- CREATE TRIGGER ` {$ ftsTable }_ad` AFTER DELETE ON ` {$ parentTable }` BEGIN
536- INSERT INTO ` {$ ftsTable }` (` {$ ftsTable }`, rowid, {$ columnList }) VALUES ('delete', OLD.`_id`, {$ oldColumnList });
537- END
538- " ;
539- $ this ->getPDO ()->prepare ($ deleteTrigger )->execute ();
540-
541- $ updateTrigger = "
542- CREATE TRIGGER ` {$ ftsTable }_au` AFTER UPDATE ON ` {$ parentTable }` BEGIN
543- INSERT INTO ` {$ ftsTable }` (` {$ ftsTable }`, rowid, {$ columnList }) VALUES ('delete', OLD.`_id`, {$ oldColumnList });
544- INSERT INTO ` {$ ftsTable }` (rowid, {$ columnList }) VALUES (NEW.`_id`, {$ newColumnList });
545- END
546- " ;
547- $ this ->getPDO ()->prepare ($ updateTrigger )->execute ();
548-
549- $ backfill = "INSERT INTO ` {$ ftsTable }` (rowid, {$ columnList }) SELECT `_id`, {$ columnList } FROM ` {$ parentTable }` " ;
550- $ this ->getPDO ()->prepare ($ backfill )->execute ();
532+ // Wrap setup + backfill in a transaction so a mid-backfill failure
533+ // doesn't leave triggers wired up to a partially populated FTS5
534+ // index — the next document write would silently desync.
535+ $ this ->startTransaction ();
536+ try {
537+ $ createSql = "CREATE VIRTUAL TABLE ` {$ ftsTable }` USING fts5( {$ columnList }, content=` {$ parentTable }`, content_rowid=`_id`) " ;
538+ $ createSql = $ this ->trigger (Database::EVENT_INDEX_CREATE , $ createSql );
539+ $ this ->getPDO ()->prepare ($ createSql )->execute ();
540+
541+ $ insertTrigger = "
542+ CREATE TRIGGER ` {$ ftsTable }_ai` AFTER INSERT ON ` {$ parentTable }` BEGIN
543+ INSERT INTO ` {$ ftsTable }` (rowid, {$ columnList }) VALUES (NEW.`_id`, {$ newColumnList });
544+ END
545+ " ;
546+ $ this ->getPDO ()->prepare ($ insertTrigger )->execute ();
547+
548+ $ deleteTrigger = "
549+ CREATE TRIGGER ` {$ ftsTable }_ad` AFTER DELETE ON ` {$ parentTable }` BEGIN
550+ INSERT INTO ` {$ ftsTable }` (` {$ ftsTable }`, rowid, {$ columnList }) VALUES ('delete', OLD.`_id`, {$ oldColumnList });
551+ END
552+ " ;
553+ $ this ->getPDO ()->prepare ($ deleteTrigger )->execute ();
554+
555+ $ updateTrigger = "
556+ CREATE TRIGGER ` {$ ftsTable }_au` AFTER UPDATE ON ` {$ parentTable }` BEGIN
557+ INSERT INTO ` {$ ftsTable }` (` {$ ftsTable }`, rowid, {$ columnList }) VALUES ('delete', OLD.`_id`, {$ oldColumnList });
558+ INSERT INTO ` {$ ftsTable }` (rowid, {$ columnList }) VALUES (NEW.`_id`, {$ newColumnList });
559+ END
560+ " ;
561+ $ this ->getPDO ()->prepare ($ updateTrigger )->execute ();
562+
563+ $ backfill = "INSERT INTO ` {$ ftsTable }` (rowid, {$ columnList }) SELECT `_id`, {$ columnList } FROM ` {$ parentTable }` " ;
564+ $ this ->getPDO ()->prepare ($ backfill )->execute ();
565+
566+ $ this ->commitTransaction ();
567+ } catch (PDOException $ e ) {
568+ $ this ->rollbackTransaction ();
569+ throw $ e ;
570+ }
551571
552572 return true ;
553573 }
@@ -583,14 +603,23 @@ public function deleteIndex(string $collection, string $id): bool
583603 $ name = $ this ->filter ($ collection );
584604 $ id = $ this ->filter ($ id );
585605
586- // FTS5 indexes live as a virtual table named after the indexed
587- // attributes, not the index id, so probe by index id first to find
588- // the matching table and drop it together with its triggers.
589- if ($ this ->dropFulltextIndexById ($ name , $ id )) {
606+ // If a regular SQLite index with this id exists, take the normal
607+ // DROP INDEX path. Otherwise the index is either an FTS5 virtual
608+ // table (whose name is keyed off attributes, not the id) or
609+ // already absent — try the FTS5 path before erroring.
610+ $ regularIndex = "{$ this ->getNamespace ()}_ {$ this ->tenant }_ {$ name }_ {$ id }" ;
611+ $ stmt = $ this ->getPDO ()->prepare ("
612+ SELECT name FROM sqlite_master WHERE type='index' AND name=:_index
613+ " );
614+ $ stmt ->bindValue (':_index ' , $ regularIndex );
615+ $ stmt ->execute ();
616+ $ hasRegular = $ stmt ->fetchColumn () !== false ;
617+
618+ if (!$ hasRegular && $ this ->dropFulltextIndexById ($ name , $ id )) {
590619 return true ;
591620 }
592621
593- $ sql = "DROP INDEX ` {$ this -> getNamespace ()} _ { $ this -> tenant } _ { $ name } _ { $ id }` " ;
622+ $ sql = "DROP INDEX ` {$ regularIndex }` " ;
594623 $ sql = $ this ->trigger (Database::EVENT_INDEX_DELETE , $ sql );
595624
596625 try {
@@ -2368,7 +2397,10 @@ public function getSchemaAttributes(string $collection): array
23682397
23692398 /**
23702399 * Introspect a collection's indexes via PRAGMA index_list +
2371- * PRAGMA index_info. Mirrors MariaDB's INFORMATION_SCHEMA shape.
2400+ * PRAGMA index_info. Returns one Document per index with a `columns`
2401+ * array, matching the grouped shape MariaDB::getSchemaIndexes returns
2402+ * so Database::createIndex can compare `columns` against the requested
2403+ * attributes without special-casing the adapter.
23722404 *
23732405 * @return array<Document>
23742406 */
@@ -2379,7 +2411,6 @@ public function getSchemaIndexes(string $collection): array
23792411 $ stmt = $ this ->getPDO ()->prepare ("PRAGMA index_list(` {$ table }`) " );
23802412 $ stmt ->execute ();
23812413 $ indexes = $ stmt ->fetchAll ();
2382- $ stmt ->closeCursor ();
23832414
23842415 $ results = [];
23852416 foreach ($ indexes as $ index ) {
@@ -2389,22 +2420,24 @@ public function getSchemaIndexes(string $collection): array
23892420 $ colStmt = $ this ->getPDO ()->prepare ("PRAGMA index_info(` {$ name }`) " );
23902421 $ colStmt ->execute ();
23912422 $ cols = $ colStmt ->fetchAll ();
2392- $ colStmt ->closeCursor ();
2423+
2424+ \usort ($ cols , fn ($ a , $ b ) => ((int ) $ a ['seqno ' ]) <=> ((int ) $ b ['seqno ' ]));
23932425
23942426 $ columns = [];
2427+ $ lengths = [];
23952428 foreach ($ cols as $ col ) {
2396- $ columns [] = new Document ([
2397- '$id ' => $ name ,
2398- 'indexName ' => $ name ,
2399- 'columnName ' => $ col ['name ' ],
2400- 'nonUnique ' => $ unique ? '0 ' : '1 ' ,
2401- 'seqInIndex ' => (string ) ($ col ['seqno ' ] + 1 ),
2402- 'indexType ' => 'BTREE ' ,
2403- 'subPart ' => null ,
2404- ]);
2429+ $ columns [] = $ col ['name ' ];
2430+ $ lengths [] = null ;
24052431 }
24062432
2407- $ results = \array_merge ($ results , $ columns );
2433+ $ results [] = new Document ([
2434+ '$id ' => $ name ,
2435+ 'indexName ' => $ name ,
2436+ 'indexType ' => 'BTREE ' ,
2437+ 'nonUnique ' => $ unique ? 0 : 1 ,
2438+ 'columns ' => $ columns ,
2439+ 'lengths ' => $ lengths ,
2440+ ]);
24082441 }
24092442
24102443 return $ results ;
@@ -2433,9 +2466,13 @@ private function parseSqliteColumnType(string $declaration): array
24332466
24342467 $ base = $ declaration ;
24352468 $ argument = null ;
2436- if (\preg_match ('/^([A-Za-z]+)\s*\((\d+)/ ' , $ declaration , $ matches ) === 1 ) {
2469+ $ secondArgument = null ;
2470+ if (\preg_match ('/^([A-Za-z]+)\s*\((\d+)(?:\s*,\s*(\d+))?/ ' , $ declaration , $ matches ) === 1 ) {
24372471 $ base = $ matches [1 ];
24382472 $ argument = (int ) $ matches [2 ];
2473+ if (isset ($ matches [3 ]) && $ matches [3 ] !== '' ) {
2474+ $ secondArgument = (int ) $ matches [3 ];
2475+ }
24392476 }
24402477
24412478 $ dataType = \strtolower ($ base );
@@ -2463,16 +2500,16 @@ private function parseSqliteColumnType(string $declaration): array
24632500 break ;
24642501
24652502 case 'text ' :
2466- $ result ['characterMaximumLength ' ] = ' 65535 ' ;
2503+ $ result ['characterMaximumLength ' ] = self :: MARIADB_TEXT_BYTES ;
24672504 break ;
24682505
24692506 case 'mediumtext ' :
2470- $ result ['characterMaximumLength ' ] = ' 16777215 ' ;
2507+ $ result ['characterMaximumLength ' ] = self :: MARIADB_MEDIUMTEXT_BYTES ;
24712508 break ;
24722509
24732510 case 'longtext ' :
24742511 case 'json ' :
2475- $ result ['characterMaximumLength ' ] = ' 4294967295 ' ;
2512+ $ result ['characterMaximumLength ' ] = self :: MARIADB_LONGTEXT_BYTES ;
24762513 break ;
24772514
24782515 case 'datetime ' :
@@ -2507,7 +2544,7 @@ private function parseSqliteColumnType(string $declaration): array
25072544 case 'decimal ' :
25082545 case 'numeric ' :
25092546 $ result ['numericPrecision ' ] = $ argument !== null ? (string ) $ argument : '10 ' ;
2510- $ result ['numericScale ' ] = '0 ' ;
2547+ $ result ['numericScale ' ] = $ secondArgument !== null ? ( string ) $ secondArgument : '0 ' ;
25112548 break ;
25122549
25132550 case 'float ' :
@@ -2573,14 +2610,58 @@ protected function getSQLCondition(Query $query, array &$binds): string
25732610 return $ method === Query::TYPE_SEARCH ? $ sql : "NOT ( {$ sql }) " ;
25742611 }
25752612
2576- $ ftsTable = $ this ->getFulltextTableName ($ collection , $ attribute );
2613+ // Look the FTS5 table up by attribute rather than computing the
2614+ // name from the attribute alone — multi-column fulltext indexes
2615+ // encode their full sorted attribute set in the table name, so
2616+ // single-attribute searches against them would otherwise miss.
2617+ $ ftsTable = $ this ->findFulltextTableForAttribute ($ collection , $ attribute );
2618+ if ($ ftsTable === null ) {
2619+ $ binds [": {$ placeholder }_0 " ] = '% ' . $ value . '% ' ;
2620+ $ sql = "{$ alias }. {$ this ->quote ($ attribute )} LIKE : {$ placeholder }_0 " ;
2621+
2622+ return $ method === Query::TYPE_SEARCH ? $ sql : "NOT ( {$ sql }) " ;
2623+ }
2624+
25772625 $ binds [": {$ placeholder }_0 " ] = $ value ;
25782626
25792627 $ subquery = "{$ alias }.`_id` IN (SELECT rowid FROM ` {$ ftsTable }` WHERE ` {$ ftsTable }` MATCH : {$ placeholder }_0) " ;
25802628
25812629 return $ method === Query::TYPE_SEARCH ? $ subquery : "NOT ( {$ subquery }) " ;
25822630 }
25832631
2632+ /**
2633+ * Find the FTS5 virtual table on `$collection` that covers `$attribute`,
2634+ * or null if none exists. Resolves the multi-column case where the
2635+ * stored table name is keyed off the full sorted attribute set and
2636+ * can't be reconstructed from a single attribute.
2637+ */
2638+ protected function findFulltextTableForAttribute (string $ collection , string $ attribute ): ?string
2639+ {
2640+ $ prefix = "{$ this ->getNamespace ()}_ {$ this ->tenant }_ {$ collection }_ " ;
2641+ $ stmt = $ this ->getPDO ()->prepare ("
2642+ SELECT name FROM sqlite_master
2643+ WHERE type='table'
2644+ AND name LIKE :_prefix
2645+ AND name LIKE '%_fts'
2646+ " );
2647+ $ stmt ->bindValue (':_prefix ' , $ prefix . '% ' );
2648+ $ stmt ->execute ();
2649+ $ tables = $ stmt ->fetchAll (PDO ::FETCH_COLUMN );
2650+
2651+ foreach ($ tables as $ table ) {
2652+ $ info = $ this ->getPDO ()->prepare ("PRAGMA table_info(` {$ table }`) " );
2653+ $ info ->execute ();
2654+ $ cols = $ info ->fetchAll (PDO ::FETCH_ASSOC );
2655+ foreach ($ cols as $ col ) {
2656+ if (($ col ['name ' ] ?? null ) === $ attribute ) {
2657+ return $ table ;
2658+ }
2659+ }
2660+ }
2661+
2662+ return null ;
2663+ }
2664+
25842665 /**
25852666 * Compile STARTS_WITH / ENDS_WITH (and their NOT variants) into LIKE
25862667 * with an explicit ESCAPE '\' clause. Inherited escapeWildcards()
0 commit comments