resplite 1.4.10 → 1.4.14
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/README.md +1 -1
- package/package.json +1 -1
- package/spec/09-search-ft-commands.md +34 -0
- package/src/commands/ft/parser.js +16 -0
- package/src/commands/ft-get.js +21 -0
- package/src/commands/registry.js +2 -0
- package/src/storage/sqlite/search.js +30 -3
- package/test/integration/search.test.js +45 -0
- package/test/unit/ft-parser.test.js +15 -0
- package/test/unit/search.test.js +18 -0
package/README.md
CHANGED
|
@@ -578,7 +578,7 @@ To reproduce the benchmark, run `npm run benchmark -- --template default`. Numbe
|
|
|
578
578
|
| **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD, SPOP, SRANDMEMBER |
|
|
579
579
|
| **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, LSET, LTRIM, BLPOP, BRPOP |
|
|
580
580
|
| **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZRANK, ZREVRANK, ZCOUNT, ZINCRBY, ZREMRANGEBYRANK, ZREMRANGEBYSCORE |
|
|
581
|
-
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
581
|
+
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.GET, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
582
582
|
| **Introspection** | TYPE, OBJECT IDLETIME, SCAN, KEYS, RENAME, MONITOR |
|
|
583
583
|
| **Admin** | SQLITE.INFO, CACHE.INFO, MEMORY.INFO |
|
|
584
584
|
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ Originally Appendix D. Goals, data model, FT.CREATE/ADD/DEL/SEARCH/SUG* behavior
|
|
|
11
11
|
* `FT.INFO`
|
|
12
12
|
* `FT.ADD`
|
|
13
13
|
* `FT.DEL`
|
|
14
|
+
* `FT.GET`
|
|
14
15
|
* `FT.SUGADD`
|
|
15
16
|
* `FT.SUGGET`
|
|
16
17
|
* `FT.SUGDEL`
|
|
@@ -254,6 +255,31 @@ RESP Integer:
|
|
|
254
255
|
|
|
255
256
|
---
|
|
256
257
|
|
|
258
|
+
# D.7.5 FT.GET
|
|
259
|
+
|
|
260
|
+
Aligned with RediSearch: load a single document by id from the index (not from the Redis keyspace).
|
|
261
|
+
|
|
262
|
+
## D.7.5.1 Syntax
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
FT.GET {index} {doc_id}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## D.7.5.2 Behavior
|
|
269
|
+
|
|
270
|
+
* If the index does not exist: error `Unknown index name`.
|
|
271
|
+
* If the document is not in `search_docs__idx`: RESP **nil** (null bulk).
|
|
272
|
+
* Otherwise: RESP **array** of field names and values, `[field, value, ...]`, in **schema field order** (same field names as `FT.ADD`).
|
|
273
|
+
* Empty string field values are returned as **nil** (same idea as RediSearch’s `HGETALL`-based reply for empty values).
|
|
274
|
+
|
|
275
|
+
## D.7.5.3 Errors
|
|
276
|
+
|
|
277
|
+
* Wrong arity / bad tokens: `-ERR syntax error`
|
|
278
|
+
* Invalid index name: `-ERR invalid index name`
|
|
279
|
+
* Index missing: `-Unknown index name`
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
257
283
|
# D.8 FT.SEARCH
|
|
258
284
|
|
|
259
285
|
## D.8.1 Required syntax (your example)
|
|
@@ -804,6 +830,14 @@ FT.DEL IndexName DocId
|
|
|
804
830
|
|
|
805
831
|
Return integer 1/0.
|
|
806
832
|
|
|
833
|
+
### FT.GET
|
|
834
|
+
|
|
835
|
+
```
|
|
836
|
+
FT.GET IndexName DocId
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
Same `DocId` rules as `FT.DEL`. Return: flat array of fields and values, or nil if the document is not indexed.
|
|
840
|
+
|
|
807
841
|
### FT.SEARCH
|
|
808
842
|
|
|
809
843
|
```
|
|
@@ -131,6 +131,22 @@ export function parseFtDel(args) {
|
|
|
131
131
|
return { indexName, docId };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Parse FT.GET args: IndexName DocId (same doc id rules as FT.DEL)
|
|
136
|
+
* @param {Buffer[]} args
|
|
137
|
+
* @returns {{ indexName: string, docId: string } | { error: string }}
|
|
138
|
+
*/
|
|
139
|
+
export function parseFtGet(args) {
|
|
140
|
+
if (!args || args.length !== 2) return { error: 'ERR syntax error' };
|
|
141
|
+
expectUtf8(args[0]);
|
|
142
|
+
expectUtf8(args[1]);
|
|
143
|
+
const indexName = toStr(args[0]).trim();
|
|
144
|
+
const docId = toStr(args[1]);
|
|
145
|
+
if (!INDEX_NAME_RE.test(indexName)) return { error: 'ERR invalid index name' };
|
|
146
|
+
if (docId.length < 1 || docId.length > 256 || docId.includes('\u0000')) return { error: 'ERR syntax error' };
|
|
147
|
+
return { indexName, docId };
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
/**
|
|
135
151
|
* Parse FT.SEARCH args: IndexName Query [NOCONTENT] [LIMIT offset count]
|
|
136
152
|
* @param {Buffer[]} args
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FT.GET index doc_id — return document fields as a flat array [field, value, ...] or nil if missing.
|
|
3
|
+
* Matches RediSearch: single doc by id; unknown index errors; not indexed returns null.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseFtGet } from './ft/parser.js';
|
|
7
|
+
import { getDocumentFields } from '../storage/sqlite/search.js';
|
|
8
|
+
|
|
9
|
+
export function handleFtGet(engine, args) {
|
|
10
|
+
const db = engine._db;
|
|
11
|
+
if (!db) return { error: 'ERR SQLite not available' };
|
|
12
|
+
const parsed = parseFtGet(args);
|
|
13
|
+
if (parsed.error) return { error: parsed.error };
|
|
14
|
+
try {
|
|
15
|
+
const fields = getDocumentFields(db, parsed.indexName, parsed.docId);
|
|
16
|
+
return fields;
|
|
17
|
+
} catch (e) {
|
|
18
|
+
const msg = e?.message ?? String(e);
|
|
19
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -79,6 +79,7 @@ import * as ftCreate from './ft-create.js';
|
|
|
79
79
|
import * as ftInfo from './ft-info.js';
|
|
80
80
|
import * as ftAdd from './ft-add.js';
|
|
81
81
|
import * as ftDel from './ft-del.js';
|
|
82
|
+
import * as ftGet from './ft-get.js';
|
|
82
83
|
import * as ftSearch from './ft-search.js';
|
|
83
84
|
import * as ftSugadd from './ft-sugadd.js';
|
|
84
85
|
import * as ftSugget from './ft-sugget.js';
|
|
@@ -164,6 +165,7 @@ const HANDLERS = new Map([
|
|
|
164
165
|
['FT.INFO', (e, a) => ftInfo.handleFtInfo(e, a)],
|
|
165
166
|
['FT.ADD', (e, a) => ftAdd.handleFtAdd(e, a)],
|
|
166
167
|
['FT.DEL', (e, a) => ftDel.handleFtDel(e, a)],
|
|
168
|
+
['FT.GET', (e, a) => ftGet.handleFtGet(e, a)],
|
|
167
169
|
['FT.SEARCH', (e, a) => ftSearch.handleFtSearch(e, a)],
|
|
168
170
|
['FT.SUGADD', (e, a) => ftSugadd.handleFtSugadd(e, a)],
|
|
169
171
|
['FT.SUGGET', (e, a) => ftSugget.handleFtSugget(e, a)],
|
|
@@ -154,7 +154,11 @@ export function addDocument(db, idx, docId, score, replace, fields) {
|
|
|
154
154
|
let ftsRowid;
|
|
155
155
|
if (existing) {
|
|
156
156
|
if (!replace) throw new Error('ERR document exists');
|
|
157
|
-
|
|
157
|
+
// FTS5 contentless bug: INSERT OR REPLACE doesn't remove old tokens.
|
|
158
|
+
// Solution: assign a new fts_rowid to avoid token pollution.
|
|
159
|
+
const maxRow = db.prepare(`SELECT COALESCE(MAX(fts_rowid), 0) AS m FROM ${docmapT}`).get();
|
|
160
|
+
ftsRowid = maxRow.m + 1;
|
|
161
|
+
db.prepare(`UPDATE ${docmapT} SET fts_rowid = ? WHERE doc_id = ?`).run(ftsRowid, docId);
|
|
158
162
|
} else {
|
|
159
163
|
const maxRow = db.prepare(`SELECT COALESCE(MAX(fts_rowid), 0) AS m FROM ${docmapT}`).get();
|
|
160
164
|
ftsRowid = maxRow.m + 1;
|
|
@@ -172,12 +176,12 @@ export function addDocument(db, idx, docId, score, replace, fields) {
|
|
|
172
176
|
).run(docId, score, fieldsJson, now, now);
|
|
173
177
|
}
|
|
174
178
|
|
|
175
|
-
// FTS5 contentless:
|
|
179
|
+
// FTS5 contentless: insert with new rowid (old rowid becomes orphaned and won't match via docmap join).
|
|
176
180
|
const ftsColumns = ['rowid', ...fieldNames.sort()];
|
|
177
181
|
const ftsValues = [ftsRowid, ...fieldNames.sort().map((f) => fields[f] ?? '')];
|
|
178
182
|
const placeholders = ftsValues.map(() => '?').join(', ');
|
|
179
183
|
const colList = ftsColumns.join(', ');
|
|
180
|
-
db.prepare(`INSERT
|
|
184
|
+
db.prepare(`INSERT INTO ${ftsT}(${colList}) VALUES (${placeholders})`).run(...ftsValues);
|
|
181
185
|
});
|
|
182
186
|
|
|
183
187
|
run();
|
|
@@ -190,6 +194,29 @@ export function addDocument(db, idx, docId, score, replace, fields) {
|
|
|
190
194
|
* @param {string} docId
|
|
191
195
|
* @returns {number}
|
|
192
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* Load stored fields for FT.GET: flat [field, value, ...] in schema field order.
|
|
199
|
+
* RediSearch-compatible: empty string values encode as null in RESP.
|
|
200
|
+
* @param {import('better-sqlite3').Database} db
|
|
201
|
+
* @param {string} idx
|
|
202
|
+
* @param {string} docId
|
|
203
|
+
* @returns { (string|null)[] | null } - null if document is not in the index
|
|
204
|
+
*/
|
|
205
|
+
export function getDocumentFields(db, idx, docId) {
|
|
206
|
+
const meta = getIndexMeta(db, idx);
|
|
207
|
+
const docsT = tableName(idx, 'docs');
|
|
208
|
+
const row = db.prepare(`SELECT fields_json FROM ${docsT} WHERE doc_id = ?`).get(docId);
|
|
209
|
+
if (!row) return null;
|
|
210
|
+
const fields = JSON.parse(row.fields_json);
|
|
211
|
+
const flat = [];
|
|
212
|
+
for (const f of meta.schema.fields) {
|
|
213
|
+
if (!Object.prototype.hasOwnProperty.call(fields, f.name)) continue;
|
|
214
|
+
const v = fields[f.name];
|
|
215
|
+
flat.push(f.name, v === '' ? null : v);
|
|
216
|
+
}
|
|
217
|
+
return flat;
|
|
218
|
+
}
|
|
219
|
+
|
|
193
220
|
export function deleteDocument(db, idx, docId) {
|
|
194
221
|
getIndexMeta(db, idx);
|
|
195
222
|
const docsT = tableName(idx, 'docs');
|
|
@@ -39,6 +39,17 @@ describe('Search integration', () => {
|
|
|
39
39
|
assert.equal(tryParseValue(ok, 0).value, 'OK');
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it('FT.GET returns field array for indexed doc and nil when missing', async () => {
|
|
43
|
+
const miss = await sendCommand(port, argv('FT.GET', 'names', 'no-such'));
|
|
44
|
+
assert.equal(tryParseValue(miss, 0).value, null);
|
|
45
|
+
|
|
46
|
+
const hit = await sendCommand(port, argv('FT.GET', 'names', 'DY1O2'));
|
|
47
|
+
const arr = tryParseValue(hit, 0).value;
|
|
48
|
+
assert.ok(Array.isArray(arr));
|
|
49
|
+
assert.equal(arr[0].toString?.('utf8') ?? arr[0], 'payload');
|
|
50
|
+
assert.equal(arr[1].toString?.('utf8') ?? arr[1], 'martin clasen');
|
|
51
|
+
});
|
|
52
|
+
|
|
42
53
|
it('FT.SEARCH returns NOCONTENT shape and total', async () => {
|
|
43
54
|
const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'clasen', 'NOCONTENT', 'LIMIT', '0', '25'));
|
|
44
55
|
const arr = tryParseValue(reply, 0).value;
|
|
@@ -185,6 +196,40 @@ describe('Search integration', () => {
|
|
|
185
196
|
const err = v?.error ?? v;
|
|
186
197
|
assert.ok(String(err).includes('Unknown index name'));
|
|
187
198
|
});
|
|
199
|
+
|
|
200
|
+
it('FT.SEARCH prefix query should not match unrelated terms', async () => {
|
|
201
|
+
// Create a fresh index for this test
|
|
202
|
+
await sendCommand(port, argv('FT.CREATE', 'bugtest', 'SCHEMA', 'payload', 'TEXT'));
|
|
203
|
+
|
|
204
|
+
// Add document with "gorge" first
|
|
205
|
+
await sendCommand(port, argv('FT.ADD', 'bugtest', 'DOC1', '1', 'REPLACE', 'FIELDS', 'payload', 'gorge'));
|
|
206
|
+
|
|
207
|
+
// Verify it matches gorge*
|
|
208
|
+
const gorge1 = await sendCommand(port, argv('FT.SEARCH', 'bugtest', 'gorge*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
209
|
+
const g1 = tryParseValue(gorge1, 0).value;
|
|
210
|
+
assert.equal(g1[0], 1, 'gorge* should match gorge');
|
|
211
|
+
assert.equal(g1[1].toString?.('utf8') ?? g1[1], 'DOC1');
|
|
212
|
+
|
|
213
|
+
// Replace with "martan"
|
|
214
|
+
await sendCommand(port, argv('FT.ADD', 'bugtest', 'DOC1', '1', 'REPLACE', 'FIELDS', 'payload', 'martan'));
|
|
215
|
+
|
|
216
|
+
// Verify FT.GET shows only martan
|
|
217
|
+
const get = await sendCommand(port, argv('FT.GET', 'bugtest', 'DOC1'));
|
|
218
|
+
const getArr = tryParseValue(get, 0).value;
|
|
219
|
+
assert.equal(getArr[0].toString?.('utf8') ?? getArr[0], 'payload');
|
|
220
|
+
assert.equal(getArr[1].toString?.('utf8') ?? getArr[1], 'martan');
|
|
221
|
+
|
|
222
|
+
// martan* should match
|
|
223
|
+
const martan = await sendCommand(port, argv('FT.SEARCH', 'bugtest', 'martan*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
224
|
+
const m = tryParseValue(martan, 0).value;
|
|
225
|
+
assert.equal(m[0], 1, 'martan* should match martan');
|
|
226
|
+
assert.equal(m[1].toString?.('utf8') ?? m[1], 'DOC1');
|
|
227
|
+
|
|
228
|
+
// gorge* should NOT match martan
|
|
229
|
+
const gorge2 = await sendCommand(port, argv('FT.SEARCH', 'bugtest', 'gorge*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
230
|
+
const g2 = tryParseValue(gorge2, 0).value;
|
|
231
|
+
assert.equal(g2[0], 0, 'gorge* should NOT match martan - this is the bug');
|
|
232
|
+
});
|
|
188
233
|
});
|
|
189
234
|
|
|
190
235
|
describe('Search persistence', () => {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
parseFtInfo,
|
|
6
6
|
parseFtAdd,
|
|
7
7
|
parseFtDel,
|
|
8
|
+
parseFtGet,
|
|
8
9
|
parseFtSearch,
|
|
9
10
|
parseFtSugadd,
|
|
10
11
|
parseFtSugget,
|
|
@@ -117,6 +118,20 @@ describe('FT parser', () => {
|
|
|
117
118
|
});
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
describe('parseFtGet', () => {
|
|
122
|
+
it('accepts index and doc_id', () => {
|
|
123
|
+
const r = parseFtGet([buf('names'), buf('doc1')]);
|
|
124
|
+
assert.ok(!r.error);
|
|
125
|
+
assert.equal(r.indexName, 'names');
|
|
126
|
+
assert.equal(r.docId, 'doc1');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('rejects wrong number of args', () => {
|
|
130
|
+
assert.equal(parseFtGet([buf('a')]).error, 'ERR syntax error');
|
|
131
|
+
assert.equal(parseFtGet([buf('a'), buf('b'), buf('c')]).error, 'ERR syntax error');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
120
135
|
describe('parseFtSearch', () => {
|
|
121
136
|
it('accepts index and query', () => {
|
|
122
137
|
const r = parseFtSearch([buf('names'), buf('hello')]);
|
package/test/unit/search.test.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getIndexMeta,
|
|
7
7
|
getIndexCounts,
|
|
8
8
|
addDocument,
|
|
9
|
+
getDocumentFields,
|
|
9
10
|
deleteDocument,
|
|
10
11
|
search,
|
|
11
12
|
suggestionAdd,
|
|
@@ -63,6 +64,23 @@ describe('Search layer', () => {
|
|
|
63
64
|
assert.throws(() => addDocument(db, 'names', 'doc3', 1, true, { payload: 'x', unknown: 'y' }), /unknown field/);
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
it('getDocumentFields returns null when doc missing, flat array in schema order', () => {
|
|
68
|
+
assert.equal(getDocumentFields(db, 'names', 'no-such-doc'), null);
|
|
69
|
+
addDocument(db, 'names', 'gd1', 1, true, { payload: 'hello' });
|
|
70
|
+
assert.deepEqual(getDocumentFields(db, 'names', 'gd1'), ['payload', 'hello']);
|
|
71
|
+
addDocument(db, 'names', 'gd2', 1, true, { payload: '' });
|
|
72
|
+
assert.deepEqual(getDocumentFields(db, 'names', 'gd2'), ['payload', null]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('getDocumentFields multi-field schema order', () => {
|
|
76
|
+
createIndex(db, 'mf', [
|
|
77
|
+
{ name: 'payload', type: 'TEXT' },
|
|
78
|
+
{ name: 'title', type: 'TEXT' },
|
|
79
|
+
]);
|
|
80
|
+
addDocument(db, 'mf', 'm1', 1, true, { payload: 'pval', title: 'tval' });
|
|
81
|
+
assert.deepEqual(getDocumentFields(db, 'mf', 'm1'), ['payload', 'pval', 'title', 'tval']);
|
|
82
|
+
});
|
|
83
|
+
|
|
66
84
|
it('deleteDocument returns 1 when found, 0 when not', () => {
|
|
67
85
|
addDocument(db, 'names', 'todel', 1, true, { payload: 'to delete' });
|
|
68
86
|
assert.equal(deleteDocument(db, 'names', 'todel'), 1);
|