@@ -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