Skip to content

Commit d971937

Browse files
abnegateclaude
andcommitted
fix(sqlite): resolve FTS5 indexes by id via collection metadata
dropFulltextIndexById previously threw whenever a collection had more than one FTS5 table — the id alone can't address a hash-keyed table, and the thrown error pointed callers at an attribute-set drop API that doesn't exist. Now the adapter reads the collection's index definition from metadata, hashes its attribute set the same way createFulltextIndex did, and matches it against the live FTS5 tables. The single-table fallback covers the metadata-unreachable path (rollbacks, tests that bypass createCollection). getSchemaIndexes only walked PRAGMA index_list, which never surfaces FTS5 virtual tables. Database::createIndex's existsInSchema check therefore couldn't see fulltext indexes, so drift detection missed orphaned FTS5 tables and every analysis pass would re-enter createFulltextIndex (idempotent, but pointless work). Now the schema index list also includes one FULLTEXT entry per FTS5 table, with the id resolved from metadata when possible so id-based comparison matches MariaDB's behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7452ce9 commit d971937

1 file changed

Lines changed: 177 additions & 19 deletions

File tree

src/Database/Adapter/SQLite.php

Lines changed: 177 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -927,11 +927,8 @@ public function deleteIndex(string $collection, string $id): bool
927927
/**
928928
* Drop the FTS5 virtual table (and its triggers) corresponding to the
929929
* fulltext index `$id` on `$collection`, if one exists. Returns true if
930-
* a matching table was found and dropped, false if no FTS5 table
931-
* exists for the collection. Throws when multiple FTS5 tables exist
932-
* and the id can't be unambiguously resolved — silently dropping
933-
* none would leave the caller's `deleteIndex` returning success
934-
* while the triggers and shadow tables remained intact.
930+
* a matching table was found and dropped, false if there's no FTS5
931+
* table for this id on the collection.
935932
*/
936933
protected function dropFulltextIndexById(string $collection, string $id): bool
937934
{
@@ -941,21 +938,23 @@ protected function dropFulltextIndexById(string $collection, string $id): bool
941938
return false;
942939
}
943940

944-
// The index id isn't encoded in the FTS5 table name (the name is
945-
// keyed off the sorted attribute set). When a single FTS5 table
946-
// exists for this collection, treat it as the unambiguous target;
947-
// otherwise raise an explicit error rather than silently leaving
948-
// the tables in place — the deleteIndex fallthrough swallows
949-
// "no such index" and would otherwise report success.
950-
if (\count($tables) !== 1) {
951-
throw new DatabaseException(
952-
"Cannot resolve fulltext index '{$id}' on '{$collection}': "
953-
. \count($tables) . ' FTS5 tables exist for this collection. '
954-
. 'Drop them by attribute set instead.'
955-
);
956-
}
941+
// FTS5 table names are keyed off a hash of the sorted attribute
942+
// set rather than the user-supplied index id, so the id alone
943+
// can't address a table directly. Resolve via the collection's
944+
// metadata: read the index definition, hash its attributes the
945+
// same way createFulltextIndex did, and compare. When metadata
946+
// isn't reachable (e.g. mid-rollback or in tests that bypass
947+
// createCollection) and exactly one FTS5 table exists, fall
948+
// back to that single table so single-index callers still work.
949+
$ftsTable = $this->resolveFulltextTableById($collection, $id, $tables);
957950

958-
$ftsTable = $tables[0];
951+
if ($ftsTable === null) {
952+
if (\count($tables) === 1) {
953+
$ftsTable = $tables[0];
954+
} else {
955+
return false;
956+
}
957+
}
959958
$triggerSuffixes = [
960959
self::FTS_TRIGGER_INSERT,
961960
self::FTS_TRIGGER_DELETE,
@@ -984,6 +983,68 @@ protected function dropFulltextIndexById(string $collection, string $id): bool
984983
return true;
985984
}
986985

986+
/**
987+
* Resolve the FTS5 table that backs index `$id` on `$collection` by
988+
* looking up the index definition in collection metadata and hashing
989+
* its attribute set the same way createFulltextIndex did. Returns
990+
* null when no metadata index exists for `$id`, when the metadata
991+
* collection itself isn't readable, or when the hashed table isn't
992+
* among the candidates passed in.
993+
*
994+
* @param array<string> $candidates
995+
*/
996+
protected function resolveFulltextTableById(string $collection, string $id, array $candidates): ?string
997+
{
998+
try {
999+
$metadataCollection = new Document(['$id' => Database::METADATA]);
1000+
$collectionDoc = $this->getDocument($metadataCollection, $collection);
1001+
} catch (\Throwable) {
1002+
return null;
1003+
}
1004+
1005+
if ($collectionDoc->isEmpty()) {
1006+
return null;
1007+
}
1008+
1009+
$indexes = $collectionDoc->getAttribute('indexes', []);
1010+
$filteredId = $this->filter($id);
1011+
1012+
foreach ($indexes as $index) {
1013+
$indexId = $index instanceof Document
1014+
? $index->getId()
1015+
: (\is_array($index) ? ($index['$id'] ?? null) : null);
1016+
1017+
if ($indexId === null) {
1018+
continue;
1019+
}
1020+
if ($this->filter((string) $indexId) !== $filteredId) {
1021+
continue;
1022+
}
1023+
1024+
$type = $index instanceof Document
1025+
? $index->getAttribute('type')
1026+
: ($index['type'] ?? null);
1027+
1028+
if ($type !== Database::INDEX_FULLTEXT) {
1029+
return null;
1030+
}
1031+
1032+
$attributes = $index instanceof Document
1033+
? $index->getAttribute('attributes', [])
1034+
: ($index['attributes'] ?? []);
1035+
1036+
$internal = \array_map(
1037+
fn ($a) => $this->getInternalKeyForAttribute((string) $a),
1038+
(array) $attributes
1039+
);
1040+
$candidate = $this->getFulltextTableName($collection, $internal);
1041+
1042+
return \in_array($candidate, $candidates, true) ? $candidate : null;
1043+
}
1044+
1045+
return null;
1046+
}
1047+
9871048
/**
9881049
* List every FTS5 virtual table belonging to `$collection`, identified
9891050
* by the prefix/suffix naming convention used when the table was
@@ -2822,9 +2883,106 @@ public function getSchemaIndexes(string $collection): array
28222883
]);
28232884
}
28242885

2886+
// PRAGMA index_list only surfaces B-tree indexes — FTS5 fulltext
2887+
// indexes live in their own virtual tables and are invisible to it.
2888+
// Append them so callers (Database::createIndex, drift detection)
2889+
// see the full set of indexes that physically exist.
2890+
foreach ($this->getFulltextSchemaIndexes($collection) as $entry) {
2891+
$results[] = new Document($entry);
2892+
}
2893+
28252894
return $results;
28262895
}
28272896

2897+
/**
2898+
* Build the schema-index entries for every FTS5 fulltext table on
2899+
* `$collection`. Each entry maps back to a metadata index id when
2900+
* possible (so createIndex's existsInSchema check matches by id like
2901+
* MariaDB does); when the metadata isn't readable we surface the
2902+
* FTS5 table name instead so the caller can still see the index
2903+
* exists.
2904+
*
2905+
* @return array<array{
2906+
* '$id': string,
2907+
* indexName: string,
2908+
* indexType: string,
2909+
* nonUnique: int,
2910+
* columns: array<string>,
2911+
* lengths: array<null>,
2912+
* }>
2913+
*/
2914+
protected function getFulltextSchemaIndexes(string $collection): array
2915+
{
2916+
$tables = $this->findFulltextTables($collection);
2917+
2918+
if (empty($tables)) {
2919+
return [];
2920+
}
2921+
2922+
$hashToId = [];
2923+
try {
2924+
$metadataCollection = new Document(['$id' => Database::METADATA]);
2925+
$collectionDoc = $this->getDocument($metadataCollection, $collection);
2926+
if (!$collectionDoc->isEmpty()) {
2927+
foreach ($collectionDoc->getAttribute('indexes', []) as $index) {
2928+
$indexId = $index instanceof Document
2929+
? $index->getId()
2930+
: (\is_array($index) ? ($index['$id'] ?? null) : null);
2931+
$type = $index instanceof Document
2932+
? $index->getAttribute('type')
2933+
: (\is_array($index) ? ($index['type'] ?? null) : null);
2934+
2935+
if ($indexId === null || $type !== Database::INDEX_FULLTEXT) {
2936+
continue;
2937+
}
2938+
2939+
$attributes = $index instanceof Document
2940+
? $index->getAttribute('attributes', [])
2941+
: ($index['attributes'] ?? []);
2942+
2943+
$internal = \array_map(
2944+
fn ($a) => $this->getInternalKeyForAttribute((string) $a),
2945+
(array) $attributes
2946+
);
2947+
$hashToId[$this->getFulltextTableName($collection, $internal)] = $this->filter((string) $indexId);
2948+
}
2949+
}
2950+
} catch (\Throwable) {
2951+
// No metadata available — fall through with the FTS5 table
2952+
// name as the entry id.
2953+
}
2954+
2955+
$entries = [];
2956+
foreach ($tables as $ftsTable) {
2957+
$info = $this->getPDO()->prepare("PRAGMA table_info(`{$ftsTable}`)");
2958+
$info->execute();
2959+
$cols = $info->fetchAll(PDO::FETCH_ASSOC);
2960+
$info->closeCursor();
2961+
2962+
$columns = [];
2963+
foreach ($cols as $col) {
2964+
$name = (string) ($col['name'] ?? '');
2965+
if ($name === '') {
2966+
continue;
2967+
}
2968+
$columns[] = $name;
2969+
}
2970+
2971+
$id = $hashToId[$ftsTable] ?? $ftsTable;
2972+
2973+
$entries[] = [
2974+
'$id' => $id,
2975+
'indexName' => $id,
2976+
'indexType' => 'FULLTEXT',
2977+
'nonUnique' => 1,
2978+
'columns' => $columns,
2979+
'lengths' => \array_fill(0, \count($columns), null),
2980+
];
2981+
}
2982+
2983+
return $entries;
2984+
}
2985+
28282986
/**
28292987
* Parse a SQLite type declaration like `VARCHAR(36)` into the column-info
28302988
* shape exposed by getSchemaAttributes. Mirrors what MariaDB returns from

0 commit comments

Comments
 (0)