@atscript/db-sqlite 0.1.149 → 0.1.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +115 -24
- package/dist/index.d.cts +18 -6
- package/dist/index.d.mts +18 -6
- package/dist/index.mjs +117 -26
- package/package.json +8 -8
package/dist/index.cjs
CHANGED
|
@@ -281,6 +281,7 @@ const sqliteDialect = {
|
|
|
281
281
|
params: [likePattern]
|
|
282
282
|
};
|
|
283
283
|
},
|
|
284
|
+
integerText: (expr) => `CAST(CAST(${expr} AS INTEGER) AS TEXT)`,
|
|
284
285
|
geoWithin(quotedCol, circle) {
|
|
285
286
|
const dist = haversineDistanceExpr(quotedCol, circle.center);
|
|
286
287
|
return {
|
|
@@ -687,6 +688,42 @@ function getSqliteTxGate(driver) {
|
|
|
687
688
|
return gate;
|
|
688
689
|
}
|
|
689
690
|
//#endregion
|
|
691
|
+
//#region src/fts-term.ts
|
|
692
|
+
const BAREWORD = /^[\p{L}\p{N}_]+$/u;
|
|
693
|
+
const KEYWORD = /^(?:AND|OR|NOT|NEAR)$/i;
|
|
694
|
+
/**
|
|
695
|
+
* Turns a user's `$search` text into a safe FTS5 MATCH expression (since
|
|
696
|
+
* 0.1.150). FTS5 reads the raw text as a query language — `-2946`, `a AND`,
|
|
697
|
+
* `title:x`, `(`, a stray `"` — and throws a syntax error on most of it.
|
|
698
|
+
* Every term is therefore quoted (plain words are already safe barewords)
|
|
699
|
+
* so no user text can raise one:
|
|
700
|
+
*
|
|
701
|
+
* - a `"quoted phrase"` stays a phrase (words in order);
|
|
702
|
+
* - a bare word stays a word, and a trailing `*` keeps its prefix meaning
|
|
703
|
+
* (`quok*` finds `quokka`);
|
|
704
|
+
* - words are AND-ed, as before;
|
|
705
|
+
* - everything else — `AND` / `OR` / `NOT`, `-`, `+`, `^`, `NEAR(…)`,
|
|
706
|
+
* `column:`, parentheses — is plain text (the tokenizer drops punctuation).
|
|
707
|
+
*
|
|
708
|
+
* Quoted strings never contain a `"`: an inner `"` is doubled (FTS5 escape).
|
|
709
|
+
*/
|
|
710
|
+
function quoteFtsTerm(text) {
|
|
711
|
+
const parts = [];
|
|
712
|
+
for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
|
|
713
|
+
const phrase = match[1];
|
|
714
|
+
if (phrase !== void 0) {
|
|
715
|
+
if (phrase.trim()) parts.push(`"${phrase.replace(/"/g, "\"\"")}"`);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const word = match[2];
|
|
719
|
+
const core = word.replace(/\*+$/, "");
|
|
720
|
+
if (!core) continue;
|
|
721
|
+
const quoted = BAREWORD.test(core) && !KEYWORD.test(core) ? core : `"${core.replace(/"/g, "\"\"")}"`;
|
|
722
|
+
parts.push(core.length < word.length ? `${quoted}*` : quoted);
|
|
723
|
+
}
|
|
724
|
+
return parts.length > 0 ? parts.join(" ") : "\"\"";
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
690
727
|
//#region src/sqlite-adapter.ts
|
|
691
728
|
/**
|
|
692
729
|
* SQLite adapter for {@link AtscriptDbTable}.
|
|
@@ -1004,7 +1041,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1004
1041
|
async aggregate(query) {
|
|
1005
1042
|
const tableName = this.resolveTableName();
|
|
1006
1043
|
const search = (0, _atscript_db_agg.resolveAggregateSearch)(query.controls);
|
|
1007
|
-
const where = search ? andWhere(this.
|
|
1044
|
+
const where = search ? andWhere(this._buildSearchPredicate(search.text, search.indexName), buildWhere(query.filter)) : buildWhere(query.filter);
|
|
1008
1045
|
if (query.controls.$count) {
|
|
1009
1046
|
const { sql, params } = buildAggregateCount(tableName, where, query.controls);
|
|
1010
1047
|
this._log(sql, params);
|
|
@@ -1014,10 +1051,10 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1014
1051
|
this._log(sql, params);
|
|
1015
1052
|
return this._stmt(() => this.driver.all(sql, params));
|
|
1016
1053
|
}
|
|
1017
|
-
async updateOne(filter, data, ops, expectedVersion) {
|
|
1054
|
+
async updateOne(filter, data, ops, expectedVersion, opts) {
|
|
1018
1055
|
const where = buildWhere(filter);
|
|
1019
1056
|
const tableName = this.resolveTableName();
|
|
1020
|
-
const versionColumn = this.
|
|
1057
|
+
const versionColumn = this._versionColumnFor(opts, expectedVersion);
|
|
1021
1058
|
const { sql, params } = buildUpdate(tableName, data, {
|
|
1022
1059
|
sql: `rowid = (SELECT rowid FROM "${esc(tableName)}" WHERE ${where.sql} LIMIT 1)`,
|
|
1023
1060
|
params: where.params
|
|
@@ -1029,9 +1066,9 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1029
1066
|
modifiedCount: result.changes
|
|
1030
1067
|
};
|
|
1031
1068
|
}
|
|
1032
|
-
async updateMany(filter, data, ops) {
|
|
1069
|
+
async updateMany(filter, data, ops, opts) {
|
|
1033
1070
|
const where = buildWhere(filter);
|
|
1034
|
-
const versionColumn = this.
|
|
1071
|
+
const versionColumn = this._versionColumnFor(opts);
|
|
1035
1072
|
const { sql, params } = buildUpdate(this.resolveTableName(), data, where, ops, versionColumn);
|
|
1036
1073
|
this._log(sql, params);
|
|
1037
1074
|
const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
|
|
@@ -1376,12 +1413,14 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1376
1413
|
}
|
|
1377
1414
|
getSearchIndexes() {
|
|
1378
1415
|
const indexes = [];
|
|
1379
|
-
|
|
1416
|
+
const ftAll = this._getFulltextIndexes();
|
|
1417
|
+
const ftDefault = (0, _atscript_db.defaultFulltextIndex)(ftAll);
|
|
1418
|
+
for (const idx of ftAll) indexes.push({
|
|
1380
1419
|
name: idx.name,
|
|
1381
|
-
description: `FTS5 index (${
|
|
1420
|
+
description: (0, _atscript_db.describeFulltext)(idx, (names) => `FTS5 index (${names})`),
|
|
1382
1421
|
type: "text",
|
|
1383
1422
|
fields: this._indexLogicalPaths(idx),
|
|
1384
|
-
isDefault:
|
|
1423
|
+
isDefault: idx === ftDefault
|
|
1385
1424
|
});
|
|
1386
1425
|
let firstVector = true;
|
|
1387
1426
|
for (const [field, vec] of this._vectorFields) {
|
|
@@ -1398,6 +1437,12 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1398
1437
|
}
|
|
1399
1438
|
async search(text, query, indexName) {
|
|
1400
1439
|
if (!text.trim()) return [];
|
|
1440
|
+
const plan = this._searchPlan(text, indexName);
|
|
1441
|
+
if (!plan.join) {
|
|
1442
|
+
const { sql, params } = buildSelect(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls);
|
|
1443
|
+
this._log(sql, params);
|
|
1444
|
+
return this._stmt(() => this.driver.all(sql, params));
|
|
1445
|
+
}
|
|
1401
1446
|
const base = this._buildFtsBase(text, query.filter, indexName);
|
|
1402
1447
|
const controls = query.controls || {};
|
|
1403
1448
|
let cols = "t.*";
|
|
@@ -1427,12 +1472,22 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1427
1472
|
count: 0
|
|
1428
1473
|
};
|
|
1429
1474
|
const data = await this.search(text, query, indexName);
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1432
|
-
|
|
1475
|
+
const plan = this._searchPlan(text, indexName);
|
|
1476
|
+
let countSql;
|
|
1477
|
+
let countParams;
|
|
1478
|
+
if (plan.join) {
|
|
1479
|
+
const base = this._buildFtsBase(text, query.filter, indexName);
|
|
1480
|
+
countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
|
|
1481
|
+
countParams = base.params;
|
|
1482
|
+
} else {
|
|
1483
|
+
const where = andWhere(plan.predicate, buildWhere(query.filter));
|
|
1484
|
+
countSql = `SELECT COUNT(*) as cnt FROM "${esc(this.resolveTableName())}" WHERE ${where.sql}`;
|
|
1485
|
+
countParams = where.params;
|
|
1486
|
+
}
|
|
1487
|
+
this._log(countSql, countParams);
|
|
1433
1488
|
return {
|
|
1434
1489
|
data,
|
|
1435
|
-
count: (await this._stmt(() => this.driver.get(countSql,
|
|
1490
|
+
count: (await this._stmt(() => this.driver.get(countSql, countParams)))?.cnt ?? 0
|
|
1436
1491
|
};
|
|
1437
1492
|
}
|
|
1438
1493
|
/** Builds FTS table name from index name: `<table>__fts__<indexName>`. */
|
|
@@ -1454,26 +1509,51 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1454
1509
|
if (!found) throw new Error((0, _atscript_db.searchIndexNotFoundMessage)(indexName));
|
|
1455
1510
|
return found;
|
|
1456
1511
|
}
|
|
1457
|
-
return ftIndexes
|
|
1512
|
+
return (0, _atscript_db.defaultFulltextIndex)(ftIndexes);
|
|
1458
1513
|
}
|
|
1459
1514
|
/**
|
|
1460
|
-
* The
|
|
1461
|
-
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)
|
|
1515
|
+
* The search restated as a standalone WHERE fragment: the FTS5 match
|
|
1516
|
+
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
|
|
1517
|
+
* OR exact equality on each integer member when the whole term is a whole
|
|
1518
|
+
* number (since 0.1.150).
|
|
1462
1519
|
*
|
|
1463
1520
|
* The external-content FTS5 tables this adapter creates carry
|
|
1464
1521
|
* `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
|
|
1465
1522
|
* table's rowid, and the unqualified `rowid` outside the subquery resolves
|
|
1466
1523
|
* against the aggregate's single FROM table. {@link _buildFtsBase} is the
|
|
1467
1524
|
* leaf path's JOIN form, which the unaliased aggregate builders cannot take.
|
|
1525
|
+
* SQLite's multi-index OR optimization serves each arm from its own index
|
|
1526
|
+
* (the FTS5 rowid lookup and the member's btree).
|
|
1468
1527
|
*
|
|
1469
1528
|
* Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
|
|
1470
1529
|
* does, so a named `$index` and its "not found" error behave identically.
|
|
1471
1530
|
*/
|
|
1472
|
-
|
|
1473
|
-
|
|
1531
|
+
_buildSearchPredicate(text, indexName) {
|
|
1532
|
+
return this._searchPlan(text, indexName).predicate;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* How a search runs: the standalone predicate, and whether the leaf path
|
|
1536
|
+
* may keep its FTS5 JOIN form (text members and no numeric branch — zero
|
|
1537
|
+
* behavior change for text-only searches).
|
|
1538
|
+
*/
|
|
1539
|
+
_searchPlan(text, indexName) {
|
|
1540
|
+
const index = this._resolveFtsIndex(indexName);
|
|
1541
|
+
const { text: textFields, integer: integerFields } = (0, _atscript_db.splitFulltextFields)(index);
|
|
1542
|
+
const n = integerFields.length > 0 ? (0, _atscript_db.searchTermInteger)(text) : void 0;
|
|
1543
|
+
const parts = [];
|
|
1544
|
+
const params = [];
|
|
1545
|
+
if (textFields.length > 0) {
|
|
1546
|
+
const quoted = `"${esc(this._ftsTableName(index.name))}"`;
|
|
1547
|
+
parts.push(`rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`);
|
|
1548
|
+
params.push(quoteFtsTerm(text));
|
|
1549
|
+
}
|
|
1550
|
+
if (n !== void 0) for (const f of integerFields) {
|
|
1551
|
+
parts.push(`"${esc(f.name)}" = ?`);
|
|
1552
|
+
params.push(n);
|
|
1553
|
+
}
|
|
1474
1554
|
return {
|
|
1475
|
-
|
|
1476
|
-
|
|
1555
|
+
predicate: (0, _atscript_db_sql_tools.orFragment)(parts, params),
|
|
1556
|
+
join: textFields.length > 0 && n === void 0
|
|
1477
1557
|
};
|
|
1478
1558
|
}
|
|
1479
1559
|
/**
|
|
@@ -1488,7 +1568,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1488
1568
|
let fromWhere = `FROM "${esc(tableName)}" AS t`;
|
|
1489
1569
|
fromWhere += ` JOIN "${esc(ftsTable)}" AS fts ON t.rowid = fts.rowid`;
|
|
1490
1570
|
fromWhere += ` WHERE fts."${esc(ftsTable)}" MATCH ?`;
|
|
1491
|
-
const params = [text];
|
|
1571
|
+
const params = [quoteFtsTerm(text)];
|
|
1492
1572
|
if (where.sql !== "1=1") {
|
|
1493
1573
|
fromWhere += ` AND (${where.sql})`;
|
|
1494
1574
|
params.push(...where.params);
|
|
@@ -1502,7 +1582,7 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1502
1582
|
* Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
|
|
1503
1583
|
*/
|
|
1504
1584
|
_syncFtsIndexes(tableName) {
|
|
1505
|
-
const ftIndexes = this._getFulltextIndexes();
|
|
1585
|
+
const ftIndexes = this._getFulltextIndexes().filter((idx) => (0, _atscript_db.splitFulltextFields)(idx).text.length > 0);
|
|
1506
1586
|
const desiredFtsTables = new Set(ftIndexes.map((idx) => this._ftsTableName(idx.name)));
|
|
1507
1587
|
const existingFts = this.driver.all(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE ?`, [`${tableName}__fts__%`]).filter((r) => r.sql.startsWith("CREATE VIRTUAL TABLE")).map((r) => r.name);
|
|
1508
1588
|
for (const name of existingFts) if (!desiredFtsTables.has(name)) this._dropFtsTable(name);
|
|
@@ -1510,16 +1590,27 @@ var SqliteAdapter = class SqliteAdapter extends _atscript_db.BaseDbAdapter {
|
|
|
1510
1590
|
for (const index of ftIndexes) {
|
|
1511
1591
|
const ftsTable = this._ftsTableName(index.name);
|
|
1512
1592
|
if (!existingSet.has(ftsTable)) this._createFtsTable(tableName, ftsTable, index);
|
|
1593
|
+
else if (!this._ftsColumnsMatch(ftsTable, index)) {
|
|
1594
|
+
this._dropFtsTable(ftsTable);
|
|
1595
|
+
this._createFtsTable(tableName, ftsTable, index);
|
|
1596
|
+
}
|
|
1513
1597
|
}
|
|
1514
1598
|
}
|
|
1599
|
+
/** Whether an existing FTS5 table has exactly the index's text columns, in order. */
|
|
1600
|
+
_ftsColumnsMatch(ftsTable, index) {
|
|
1601
|
+
const live = this.driver.all(`PRAGMA table_info("${esc(ftsTable)}")`).map((c) => c.name);
|
|
1602
|
+
const desired = (0, _atscript_db.splitFulltextFields)(index).text.map((f) => f.name);
|
|
1603
|
+
return live.length === desired.length && live.every((name, i) => name === desired[i]);
|
|
1604
|
+
}
|
|
1515
1605
|
/** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
|
|
1516
1606
|
_createFtsTable(tableName, ftsTable, index) {
|
|
1517
|
-
const
|
|
1607
|
+
const fields = (0, _atscript_db.splitFulltextFields)(index).text;
|
|
1608
|
+
const fieldList = fields.map((f) => `"${esc(f.name)}"`).join(", ");
|
|
1518
1609
|
const createSql = `CREATE VIRTUAL TABLE IF NOT EXISTS "${esc(ftsTable)}" USING fts5(${fieldList}, content='${tableName.replace(/'/g, "''")}', content_rowid='rowid')`;
|
|
1519
1610
|
this._log(createSql);
|
|
1520
1611
|
this.driver.exec(createSql);
|
|
1521
|
-
const newFields =
|
|
1522
|
-
const oldFields =
|
|
1612
|
+
const newFields = fields.map((f) => `new."${esc(f.name)}"`).join(", ");
|
|
1613
|
+
const oldFields = fields.map((f) => `old."${esc(f.name)}"`).join(", ");
|
|
1523
1614
|
const ef = esc(ftsTable);
|
|
1524
1615
|
const aiSql = `CREATE TRIGGER IF NOT EXISTS "${esc(ftsTable + "__ai")}" AFTER INSERT ON "${esc(tableName)}" BEGIN INSERT INTO "${ef}"(rowid, ${fieldList}) VALUES (new.rowid, ${newFields}); END`;
|
|
1525
1616
|
this._log(aiSql);
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
|
|
1
|
+
import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
|
|
2
2
|
import { TMetadataMap } from "@atscript/typescript/utils";
|
|
3
3
|
import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
|
|
4
4
|
import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
|
|
@@ -357,8 +357,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
|
|
|
357
357
|
findManyPerPartition(query: DbQuery, partitionBy: readonly string[]): Promise<Array<Record<string, unknown>>>;
|
|
358
358
|
count(query: DbQuery): Promise<number>;
|
|
359
359
|
aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
|
|
360
|
-
updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
|
|
361
|
-
updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
|
|
360
|
+
updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
|
|
361
|
+
updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
|
|
362
362
|
replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
|
|
363
363
|
replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
|
|
364
364
|
deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
|
|
@@ -425,19 +425,29 @@ declare class SqliteAdapter extends BaseDbAdapter {
|
|
|
425
425
|
/** Resolves a fulltext index by name, or returns the first available. */
|
|
426
426
|
private _resolveFtsIndex;
|
|
427
427
|
/**
|
|
428
|
-
* The
|
|
429
|
-
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)
|
|
428
|
+
* The search restated as a standalone WHERE fragment: the FTS5 match
|
|
429
|
+
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
|
|
430
|
+
* OR exact equality on each integer member when the whole term is a whole
|
|
431
|
+
* number (since 0.1.150).
|
|
430
432
|
*
|
|
431
433
|
* The external-content FTS5 tables this adapter creates carry
|
|
432
434
|
* `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
|
|
433
435
|
* table's rowid, and the unqualified `rowid` outside the subquery resolves
|
|
434
436
|
* against the aggregate's single FROM table. {@link _buildFtsBase} is the
|
|
435
437
|
* leaf path's JOIN form, which the unaliased aggregate builders cannot take.
|
|
438
|
+
* SQLite's multi-index OR optimization serves each arm from its own index
|
|
439
|
+
* (the FTS5 rowid lookup and the member's btree).
|
|
436
440
|
*
|
|
437
441
|
* Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
|
|
438
442
|
* does, so a named `$index` and its "not found" error behave identically.
|
|
439
443
|
*/
|
|
440
|
-
private
|
|
444
|
+
private _buildSearchPredicate;
|
|
445
|
+
/**
|
|
446
|
+
* How a search runs: the standalone predicate, and whether the leaf path
|
|
447
|
+
* may keep its FTS5 JOIN form (text members and no numeric branch — zero
|
|
448
|
+
* behavior change for text-only searches).
|
|
449
|
+
*/
|
|
450
|
+
private _searchPlan;
|
|
441
451
|
/**
|
|
442
452
|
* Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
|
|
443
453
|
* Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
|
|
@@ -447,6 +457,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
|
|
|
447
457
|
* Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
|
|
448
458
|
*/
|
|
449
459
|
private _syncFtsIndexes;
|
|
460
|
+
/** Whether an existing FTS5 table has exactly the index's text columns, in order. */
|
|
461
|
+
private _ftsColumnsMatch;
|
|
450
462
|
/** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
|
|
451
463
|
private _createFtsTable;
|
|
452
464
|
/** Drops an FTS5 virtual table and its sync triggers. */
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
|
|
1
|
+
import { AggregateFn, BaseDbAdapter, BucketUnit, DbQuery, DbSpace, FilterExpr, TColumnDiff, TDbDeleteResult, TDbFieldMeta, TDbInsertIgnoreSlot, TDbInsertManyResult, TDbInsertResult, TDbObjectKind, TDbUpdateOptions, TDbUpdateResult, TEnsureTableOptions, TExistingColumn, TFieldOps, TGenericLogger, TReferencingForeignKey, TSearchIndexInfo, TSyncColumnResult, TValueFormatterPair, TViewCapability } from "@atscript/db";
|
|
2
2
|
import { TFilterVisitorOptions, TSqlFragment, TSqlFragment as TSqlFragment$1 } from "@atscript/db-sql-tools";
|
|
3
3
|
import { FilterExpr as FilterExpr$1 } from "@uniqu/core";
|
|
4
4
|
import { TMetadataMap } from "@atscript/typescript/utils";
|
|
@@ -357,8 +357,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
|
|
|
357
357
|
findManyPerPartition(query: DbQuery, partitionBy: readonly string[]): Promise<Array<Record<string, unknown>>>;
|
|
358
358
|
count(query: DbQuery): Promise<number>;
|
|
359
359
|
aggregate(query: DbQuery): Promise<Array<Record<string, unknown>>>;
|
|
360
|
-
updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number): Promise<TDbUpdateResult>;
|
|
361
|
-
updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps): Promise<TDbUpdateResult>;
|
|
360
|
+
updateOne(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, expectedVersion?: number, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
|
|
361
|
+
updateMany(filter: FilterExpr, data: Record<string, unknown>, ops?: TFieldOps, opts?: TDbUpdateOptions): Promise<TDbUpdateResult>;
|
|
362
362
|
replaceOne(filter: FilterExpr, data: Record<string, unknown>, expectedVersion?: number): Promise<TDbUpdateResult>;
|
|
363
363
|
replaceMany(filter: FilterExpr, data: Record<string, unknown>): Promise<TDbUpdateResult>;
|
|
364
364
|
deleteOne(filter: FilterExpr): Promise<TDbDeleteResult>;
|
|
@@ -425,19 +425,29 @@ declare class SqliteAdapter extends BaseDbAdapter {
|
|
|
425
425
|
/** Resolves a fulltext index by name, or returns the first available. */
|
|
426
426
|
private _resolveFtsIndex;
|
|
427
427
|
/**
|
|
428
|
-
* The
|
|
429
|
-
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)
|
|
428
|
+
* The search restated as a standalone WHERE fragment: the FTS5 match
|
|
429
|
+
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
|
|
430
|
+
* OR exact equality on each integer member when the whole term is a whole
|
|
431
|
+
* number (since 0.1.150).
|
|
430
432
|
*
|
|
431
433
|
* The external-content FTS5 tables this adapter creates carry
|
|
432
434
|
* `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
|
|
433
435
|
* table's rowid, and the unqualified `rowid` outside the subquery resolves
|
|
434
436
|
* against the aggregate's single FROM table. {@link _buildFtsBase} is the
|
|
435
437
|
* leaf path's JOIN form, which the unaliased aggregate builders cannot take.
|
|
438
|
+
* SQLite's multi-index OR optimization serves each arm from its own index
|
|
439
|
+
* (the FTS5 rowid lookup and the member's btree).
|
|
436
440
|
*
|
|
437
441
|
* Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
|
|
438
442
|
* does, so a named `$index` and its "not found" error behave identically.
|
|
439
443
|
*/
|
|
440
|
-
private
|
|
444
|
+
private _buildSearchPredicate;
|
|
445
|
+
/**
|
|
446
|
+
* How a search runs: the standalone predicate, and whether the leaf path
|
|
447
|
+
* may keep its FTS5 JOIN form (text members and no numeric branch — zero
|
|
448
|
+
* behavior change for text-only searches).
|
|
449
|
+
*/
|
|
450
|
+
private _searchPlan;
|
|
441
451
|
/**
|
|
442
452
|
* Builds the shared FROM+JOIN+WHERE fragment for FTS5 queries.
|
|
443
453
|
* Both data and count queries reuse this to avoid duplicating index resolution and filter translation.
|
|
@@ -447,6 +457,8 @@ declare class SqliteAdapter extends BaseDbAdapter {
|
|
|
447
457
|
* Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
|
|
448
458
|
*/
|
|
449
459
|
private _syncFtsIndexes;
|
|
460
|
+
/** Whether an existing FTS5 table has exactly the index's text columns, in order. */
|
|
461
|
+
private _ftsColumnsMatch;
|
|
450
462
|
/** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
|
|
451
463
|
private _createFtsTable;
|
|
452
464
|
/** Drops an FTS5 virtual table and its sync triggers. */
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, fkColumns, searchIndexNotFoundMessage, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
2
|
+
import { ALL_AGGREGATE_FNS, ALL_BUCKET_UNITS, ALL_VIEW_CAPABILITIES, BaseDbAdapter, DbError, DbSpace, defaultFulltextIndex, describeFulltext, fkColumns, searchIndexNotFoundMessage, searchTermInteger, splitFulltextFields, vectorIndexNotFoundMessage } from "@atscript/db";
|
|
3
3
|
import { resolveAggregateSearch } from "@atscript/db/agg";
|
|
4
|
-
import { EMPTY_AND, SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, geoWindowFromControls, jsonDollarPath, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue } from "@atscript/db-sql-tools";
|
|
4
|
+
import { EMPTY_AND, SEARCH_SOURCE_ALIAS, buildAggregateCount, buildAggregateSelect, buildCreateView, buildDelete, buildGeoSearchCount, buildGeoSearchSelect, buildInsert, buildPartitionedSelect, buildSelect, buildUpdate, buildVectorSearchCount, buildVectorSearchSelect, buildWhere as buildWhere$1, defaultValueForType, defaultValueToSqlLiteral, derivedColumnExpr, fillReplacePayload, geoWindowFromControls, jsonDollarPath, orFragment, parseRegexString, refActionToSql, renameGeoDistance, replaceColumnsFor, sqlTimeZoneLiteral, stripPartitionRowNumber, toSqlValue } from "@atscript/db-sql-tools";
|
|
5
5
|
import { bucketer } from "@uniqu/core";
|
|
6
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
7
|
//#region src/better-sqlite3-driver.ts
|
|
@@ -280,6 +280,7 @@ const sqliteDialect = {
|
|
|
280
280
|
params: [likePattern]
|
|
281
281
|
};
|
|
282
282
|
},
|
|
283
|
+
integerText: (expr) => `CAST(CAST(${expr} AS INTEGER) AS TEXT)`,
|
|
283
284
|
geoWithin(quotedCol, circle) {
|
|
284
285
|
const dist = haversineDistanceExpr(quotedCol, circle.center);
|
|
285
286
|
return {
|
|
@@ -686,6 +687,42 @@ function getSqliteTxGate(driver) {
|
|
|
686
687
|
return gate;
|
|
687
688
|
}
|
|
688
689
|
//#endregion
|
|
690
|
+
//#region src/fts-term.ts
|
|
691
|
+
const BAREWORD = /^[\p{L}\p{N}_]+$/u;
|
|
692
|
+
const KEYWORD = /^(?:AND|OR|NOT|NEAR)$/i;
|
|
693
|
+
/**
|
|
694
|
+
* Turns a user's `$search` text into a safe FTS5 MATCH expression (since
|
|
695
|
+
* 0.1.150). FTS5 reads the raw text as a query language — `-2946`, `a AND`,
|
|
696
|
+
* `title:x`, `(`, a stray `"` — and throws a syntax error on most of it.
|
|
697
|
+
* Every term is therefore quoted (plain words are already safe barewords)
|
|
698
|
+
* so no user text can raise one:
|
|
699
|
+
*
|
|
700
|
+
* - a `"quoted phrase"` stays a phrase (words in order);
|
|
701
|
+
* - a bare word stays a word, and a trailing `*` keeps its prefix meaning
|
|
702
|
+
* (`quok*` finds `quokka`);
|
|
703
|
+
* - words are AND-ed, as before;
|
|
704
|
+
* - everything else — `AND` / `OR` / `NOT`, `-`, `+`, `^`, `NEAR(…)`,
|
|
705
|
+
* `column:`, parentheses — is plain text (the tokenizer drops punctuation).
|
|
706
|
+
*
|
|
707
|
+
* Quoted strings never contain a `"`: an inner `"` is doubled (FTS5 escape).
|
|
708
|
+
*/
|
|
709
|
+
function quoteFtsTerm(text) {
|
|
710
|
+
const parts = [];
|
|
711
|
+
for (const match of text.matchAll(/"([^"]*)"|(\S+)/g)) {
|
|
712
|
+
const phrase = match[1];
|
|
713
|
+
if (phrase !== void 0) {
|
|
714
|
+
if (phrase.trim()) parts.push(`"${phrase.replace(/"/g, "\"\"")}"`);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const word = match[2];
|
|
718
|
+
const core = word.replace(/\*+$/, "");
|
|
719
|
+
if (!core) continue;
|
|
720
|
+
const quoted = BAREWORD.test(core) && !KEYWORD.test(core) ? core : `"${core.replace(/"/g, "\"\"")}"`;
|
|
721
|
+
parts.push(core.length < word.length ? `${quoted}*` : quoted);
|
|
722
|
+
}
|
|
723
|
+
return parts.length > 0 ? parts.join(" ") : "\"\"";
|
|
724
|
+
}
|
|
725
|
+
//#endregion
|
|
689
726
|
//#region src/sqlite-adapter.ts
|
|
690
727
|
/**
|
|
691
728
|
* SQLite adapter for {@link AtscriptDbTable}.
|
|
@@ -1003,7 +1040,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1003
1040
|
async aggregate(query) {
|
|
1004
1041
|
const tableName = this.resolveTableName();
|
|
1005
1042
|
const search = resolveAggregateSearch(query.controls);
|
|
1006
|
-
const where = search ? andWhere(this.
|
|
1043
|
+
const where = search ? andWhere(this._buildSearchPredicate(search.text, search.indexName), buildWhere(query.filter)) : buildWhere(query.filter);
|
|
1007
1044
|
if (query.controls.$count) {
|
|
1008
1045
|
const { sql, params } = buildAggregateCount$1(tableName, where, query.controls);
|
|
1009
1046
|
this._log(sql, params);
|
|
@@ -1013,10 +1050,10 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1013
1050
|
this._log(sql, params);
|
|
1014
1051
|
return this._stmt(() => this.driver.all(sql, params));
|
|
1015
1052
|
}
|
|
1016
|
-
async updateOne(filter, data, ops, expectedVersion) {
|
|
1053
|
+
async updateOne(filter, data, ops, expectedVersion, opts) {
|
|
1017
1054
|
const where = buildWhere(filter);
|
|
1018
1055
|
const tableName = this.resolveTableName();
|
|
1019
|
-
const versionColumn = this.
|
|
1056
|
+
const versionColumn = this._versionColumnFor(opts, expectedVersion);
|
|
1020
1057
|
const { sql, params } = buildUpdate$1(tableName, data, {
|
|
1021
1058
|
sql: `rowid = (SELECT rowid FROM "${esc(tableName)}" WHERE ${where.sql} LIMIT 1)`,
|
|
1022
1059
|
params: where.params
|
|
@@ -1028,9 +1065,9 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1028
1065
|
modifiedCount: result.changes
|
|
1029
1066
|
};
|
|
1030
1067
|
}
|
|
1031
|
-
async updateMany(filter, data, ops) {
|
|
1068
|
+
async updateMany(filter, data, ops, opts) {
|
|
1032
1069
|
const where = buildWhere(filter);
|
|
1033
|
-
const versionColumn = this.
|
|
1070
|
+
const versionColumn = this._versionColumnFor(opts);
|
|
1034
1071
|
const { sql, params } = buildUpdate$1(this.resolveTableName(), data, where, ops, versionColumn);
|
|
1035
1072
|
this._log(sql, params);
|
|
1036
1073
|
const result = await this._stmt(() => this._wrapConstraintError(() => this.driver.run(sql, params)));
|
|
@@ -1375,12 +1412,14 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1375
1412
|
}
|
|
1376
1413
|
getSearchIndexes() {
|
|
1377
1414
|
const indexes = [];
|
|
1378
|
-
|
|
1415
|
+
const ftAll = this._getFulltextIndexes();
|
|
1416
|
+
const ftDefault = defaultFulltextIndex(ftAll);
|
|
1417
|
+
for (const idx of ftAll) indexes.push({
|
|
1379
1418
|
name: idx.name,
|
|
1380
|
-
description: `FTS5 index (${
|
|
1419
|
+
description: describeFulltext(idx, (names) => `FTS5 index (${names})`),
|
|
1381
1420
|
type: "text",
|
|
1382
1421
|
fields: this._indexLogicalPaths(idx),
|
|
1383
|
-
isDefault:
|
|
1422
|
+
isDefault: idx === ftDefault
|
|
1384
1423
|
});
|
|
1385
1424
|
let firstVector = true;
|
|
1386
1425
|
for (const [field, vec] of this._vectorFields) {
|
|
@@ -1397,6 +1436,12 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1397
1436
|
}
|
|
1398
1437
|
async search(text, query, indexName) {
|
|
1399
1438
|
if (!text.trim()) return [];
|
|
1439
|
+
const plan = this._searchPlan(text, indexName);
|
|
1440
|
+
if (!plan.join) {
|
|
1441
|
+
const { sql, params } = buildSelect$1(this.resolveTableName(), andWhere(plan.predicate, buildWhere(query.filter)), query.controls);
|
|
1442
|
+
this._log(sql, params);
|
|
1443
|
+
return this._stmt(() => this.driver.all(sql, params));
|
|
1444
|
+
}
|
|
1400
1445
|
const base = this._buildFtsBase(text, query.filter, indexName);
|
|
1401
1446
|
const controls = query.controls || {};
|
|
1402
1447
|
let cols = "t.*";
|
|
@@ -1426,12 +1471,22 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1426
1471
|
count: 0
|
|
1427
1472
|
};
|
|
1428
1473
|
const data = await this.search(text, query, indexName);
|
|
1429
|
-
const
|
|
1430
|
-
|
|
1431
|
-
|
|
1474
|
+
const plan = this._searchPlan(text, indexName);
|
|
1475
|
+
let countSql;
|
|
1476
|
+
let countParams;
|
|
1477
|
+
if (plan.join) {
|
|
1478
|
+
const base = this._buildFtsBase(text, query.filter, indexName);
|
|
1479
|
+
countSql = `SELECT COUNT(*) as cnt ${base.fromWhere}`;
|
|
1480
|
+
countParams = base.params;
|
|
1481
|
+
} else {
|
|
1482
|
+
const where = andWhere(plan.predicate, buildWhere(query.filter));
|
|
1483
|
+
countSql = `SELECT COUNT(*) as cnt FROM "${esc(this.resolveTableName())}" WHERE ${where.sql}`;
|
|
1484
|
+
countParams = where.params;
|
|
1485
|
+
}
|
|
1486
|
+
this._log(countSql, countParams);
|
|
1432
1487
|
return {
|
|
1433
1488
|
data,
|
|
1434
|
-
count: (await this._stmt(() => this.driver.get(countSql,
|
|
1489
|
+
count: (await this._stmt(() => this.driver.get(countSql, countParams)))?.cnt ?? 0
|
|
1435
1490
|
};
|
|
1436
1491
|
}
|
|
1437
1492
|
/** Builds FTS table name from index name: `<table>__fts__<indexName>`. */
|
|
@@ -1453,26 +1508,51 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1453
1508
|
if (!found) throw new Error(searchIndexNotFoundMessage(indexName));
|
|
1454
1509
|
return found;
|
|
1455
1510
|
}
|
|
1456
|
-
return ftIndexes
|
|
1511
|
+
return defaultFulltextIndex(ftIndexes);
|
|
1457
1512
|
}
|
|
1458
1513
|
/**
|
|
1459
|
-
* The
|
|
1460
|
-
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)
|
|
1514
|
+
* The search restated as a standalone WHERE fragment: the FTS5 match
|
|
1515
|
+
* `rowid IN (SELECT rowid FROM "<table>__fts__<idx>" WHERE "<table>__fts__<idx>" MATCH ?)`
|
|
1516
|
+
* OR exact equality on each integer member when the whole term is a whole
|
|
1517
|
+
* number (since 0.1.150).
|
|
1461
1518
|
*
|
|
1462
1519
|
* The external-content FTS5 tables this adapter creates carry
|
|
1463
1520
|
* `content_rowid='rowid'`, so the virtual table's `rowid` IS the content
|
|
1464
1521
|
* table's rowid, and the unqualified `rowid` outside the subquery resolves
|
|
1465
1522
|
* against the aggregate's single FROM table. {@link _buildFtsBase} is the
|
|
1466
1523
|
* leaf path's JOIN form, which the unaliased aggregate builders cannot take.
|
|
1524
|
+
* SQLite's multi-index OR optimization serves each arm from its own index
|
|
1525
|
+
* (the FTS5 rowid lookup and the member's btree).
|
|
1467
1526
|
*
|
|
1468
1527
|
* Index resolution goes through {@link _resolveFtsIndex}, as the leaf path
|
|
1469
1528
|
* does, so a named `$index` and its "not found" error behave identically.
|
|
1470
1529
|
*/
|
|
1471
|
-
|
|
1472
|
-
|
|
1530
|
+
_buildSearchPredicate(text, indexName) {
|
|
1531
|
+
return this._searchPlan(text, indexName).predicate;
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* How a search runs: the standalone predicate, and whether the leaf path
|
|
1535
|
+
* may keep its FTS5 JOIN form (text members and no numeric branch — zero
|
|
1536
|
+
* behavior change for text-only searches).
|
|
1537
|
+
*/
|
|
1538
|
+
_searchPlan(text, indexName) {
|
|
1539
|
+
const index = this._resolveFtsIndex(indexName);
|
|
1540
|
+
const { text: textFields, integer: integerFields } = splitFulltextFields(index);
|
|
1541
|
+
const n = integerFields.length > 0 ? searchTermInteger(text) : void 0;
|
|
1542
|
+
const parts = [];
|
|
1543
|
+
const params = [];
|
|
1544
|
+
if (textFields.length > 0) {
|
|
1545
|
+
const quoted = `"${esc(this._ftsTableName(index.name))}"`;
|
|
1546
|
+
parts.push(`rowid IN (SELECT rowid FROM ${quoted} WHERE ${quoted} MATCH ?)`);
|
|
1547
|
+
params.push(quoteFtsTerm(text));
|
|
1548
|
+
}
|
|
1549
|
+
if (n !== void 0) for (const f of integerFields) {
|
|
1550
|
+
parts.push(`"${esc(f.name)}" = ?`);
|
|
1551
|
+
params.push(n);
|
|
1552
|
+
}
|
|
1473
1553
|
return {
|
|
1474
|
-
|
|
1475
|
-
|
|
1554
|
+
predicate: orFragment(parts, params),
|
|
1555
|
+
join: textFields.length > 0 && n === void 0
|
|
1476
1556
|
};
|
|
1477
1557
|
}
|
|
1478
1558
|
/**
|
|
@@ -1487,7 +1567,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1487
1567
|
let fromWhere = `FROM "${esc(tableName)}" AS t`;
|
|
1488
1568
|
fromWhere += ` JOIN "${esc(ftsTable)}" AS fts ON t.rowid = fts.rowid`;
|
|
1489
1569
|
fromWhere += ` WHERE fts."${esc(ftsTable)}" MATCH ?`;
|
|
1490
|
-
const params = [text];
|
|
1570
|
+
const params = [quoteFtsTerm(text)];
|
|
1491
1571
|
if (where.sql !== "1=1") {
|
|
1492
1572
|
fromWhere += ` AND (${where.sql})`;
|
|
1493
1573
|
params.push(...where.params);
|
|
@@ -1501,7 +1581,7 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1501
1581
|
* Creates/drops FTS5 virtual tables and sync triggers to match desired fulltext indexes.
|
|
1502
1582
|
*/
|
|
1503
1583
|
_syncFtsIndexes(tableName) {
|
|
1504
|
-
const ftIndexes = this._getFulltextIndexes();
|
|
1584
|
+
const ftIndexes = this._getFulltextIndexes().filter((idx) => splitFulltextFields(idx).text.length > 0);
|
|
1505
1585
|
const desiredFtsTables = new Set(ftIndexes.map((idx) => this._ftsTableName(idx.name)));
|
|
1506
1586
|
const existingFts = this.driver.all(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE ?`, [`${tableName}__fts__%`]).filter((r) => r.sql.startsWith("CREATE VIRTUAL TABLE")).map((r) => r.name);
|
|
1507
1587
|
for (const name of existingFts) if (!desiredFtsTables.has(name)) this._dropFtsTable(name);
|
|
@@ -1509,16 +1589,27 @@ var SqliteAdapter = class SqliteAdapter extends BaseDbAdapter {
|
|
|
1509
1589
|
for (const index of ftIndexes) {
|
|
1510
1590
|
const ftsTable = this._ftsTableName(index.name);
|
|
1511
1591
|
if (!existingSet.has(ftsTable)) this._createFtsTable(tableName, ftsTable, index);
|
|
1592
|
+
else if (!this._ftsColumnsMatch(ftsTable, index)) {
|
|
1593
|
+
this._dropFtsTable(ftsTable);
|
|
1594
|
+
this._createFtsTable(tableName, ftsTable, index);
|
|
1595
|
+
}
|
|
1512
1596
|
}
|
|
1513
1597
|
}
|
|
1598
|
+
/** Whether an existing FTS5 table has exactly the index's text columns, in order. */
|
|
1599
|
+
_ftsColumnsMatch(ftsTable, index) {
|
|
1600
|
+
const live = this.driver.all(`PRAGMA table_info("${esc(ftsTable)}")`).map((c) => c.name);
|
|
1601
|
+
const desired = splitFulltextFields(index).text.map((f) => f.name);
|
|
1602
|
+
return live.length === desired.length && live.every((name, i) => name === desired[i]);
|
|
1603
|
+
}
|
|
1514
1604
|
/** Creates an FTS5 virtual table with sync triggers and rebuilds the index. */
|
|
1515
1605
|
_createFtsTable(tableName, ftsTable, index) {
|
|
1516
|
-
const
|
|
1606
|
+
const fields = splitFulltextFields(index).text;
|
|
1607
|
+
const fieldList = fields.map((f) => `"${esc(f.name)}"`).join(", ");
|
|
1517
1608
|
const createSql = `CREATE VIRTUAL TABLE IF NOT EXISTS "${esc(ftsTable)}" USING fts5(${fieldList}, content='${tableName.replace(/'/g, "''")}', content_rowid='rowid')`;
|
|
1518
1609
|
this._log(createSql);
|
|
1519
1610
|
this.driver.exec(createSql);
|
|
1520
|
-
const newFields =
|
|
1521
|
-
const oldFields =
|
|
1611
|
+
const newFields = fields.map((f) => `new."${esc(f.name)}"`).join(", ");
|
|
1612
|
+
const oldFields = fields.map((f) => `old."${esc(f.name)}"`).join(", ");
|
|
1522
1613
|
const ef = esc(ftsTable);
|
|
1523
1614
|
const aiSql = `CREATE TRIGGER IF NOT EXISTS "${esc(ftsTable + "__ai")}" AFTER INSERT ON "${esc(tableName)}" BEGIN INSERT INTO "${ef}"(rowid, ${fieldList}) VALUES (new.rowid, ${newFields}); END`;
|
|
1524
1615
|
this._log(aiSql);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atscript/db-sqlite",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.150",
|
|
4
4
|
"description": "SQLite adapter for @atscript/db with swappable driver support.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"atscript",
|
|
@@ -36,21 +36,21 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@atscript/core": "^0.1.
|
|
40
|
-
"@atscript/typescript": "^0.1.
|
|
39
|
+
"@atscript/core": "^0.1.101",
|
|
40
|
+
"@atscript/typescript": "^0.1.101",
|
|
41
41
|
"@types/better-sqlite3": "^7.6.13",
|
|
42
42
|
"@uniqu/core": "^0.1.13",
|
|
43
43
|
"better-sqlite3": "^12.6.2",
|
|
44
44
|
"sqlite-vec": "^0.1.9",
|
|
45
|
-
"unplugin-atscript": "^0.1.
|
|
45
|
+
"unplugin-atscript": "^0.1.101"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@atscript/core": "^0.1.
|
|
49
|
-
"@atscript/typescript": "^0.1.
|
|
48
|
+
"@atscript/core": "^0.1.101",
|
|
49
|
+
"@atscript/typescript": "^0.1.101",
|
|
50
50
|
"@uniqu/core": "^0.1.13",
|
|
51
51
|
"better-sqlite3": ">=11.0.0",
|
|
52
|
-
"@atscript/db": "^0.1.
|
|
53
|
-
"@atscript/db-sql-tools": "^0.1.
|
|
52
|
+
"@atscript/db": "^0.1.150",
|
|
53
|
+
"@atscript/db-sql-tools": "^0.1.150"
|
|
54
54
|
},
|
|
55
55
|
"peerDependenciesMeta": {
|
|
56
56
|
"better-sqlite3": {
|