Skip to content

Commit 699107c

Browse files
github-actions[bot]claude
authored andcommitted
fix(sqlite): tighten FTS5 setup, schema introspection, and deleteIndex
- createFulltextIndex now wraps virtual table + triggers + backfill in a transaction so a mid-backfill failure doesn't leave triggers wired up to a partially populated FTS5 index. - getSQLCondition's SEARCH path looks the FTS5 table up via sqlite_master + PRAGMA table_info instead of computing the name from a single attribute, so multi-column fulltext indexes (whose table name encodes the full sorted attribute set) actually match. - deleteIndex now skips the FTS5 drop path whenever a regular SQLite index with the requested id exists, preventing accidental FTS5 drops when collections carry both kinds of indexes. - getSchemaIndexes returns one Document per index with `columns` and `lengths` arrays, matching MariaDB's grouped shape so Database::createIndex's `columns === filteredAttributes` comparison works correctly. Also drops the redundant closeCursor calls. - parseSqliteColumnType captures the optional scale in DECIMAL(p,s) and hoists the TEXT-family byte ceilings into named constants. - getSizeOfCollection collapses the equivalent SUM(payload) + SUM(pgsize - unused - payload) expression to SUM(pgsize - unused). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a5b5ce1 commit 699107c

1 file changed

Lines changed: 137 additions & 56 deletions

File tree

src/Database/Adapter/SQLite.php

Lines changed: 137 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@
3535
*/
3636
class 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

Comments
 (0)