muya 2.3.3 → 2.4.0
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/esm/sqlite/__tests__/table.test.js +1 -1
- package/esm/sqlite/__tests__/tokenizer.test.js +1 -0
- package/esm/sqlite/table/index.js +1 -1
- package/esm/sqlite/table/table.js +33 -6
- package/esm/sqlite/table/tokenizer.js +1 -0
- package/esm/sqlite/table/where.js +1 -1
- package/package.json +1 -1
- package/src/sqlite/__tests__/table.test.ts +173 -3
- package/src/sqlite/__tests__/tokenizer.test.ts +43 -0
- package/src/sqlite/table/index.ts +1 -0
- package/src/sqlite/table/table.ts +101 -31
- package/src/sqlite/table/table.types.ts +21 -3
- package/src/sqlite/table/tokenizer.ts +35 -0
- package/src/sqlite/table/where.ts +24 -19
- package/types/sqlite/table/index.d.ts +1 -0
- package/types/sqlite/table/table.d.ts +7 -7
- package/types/sqlite/table/table.types.d.ts +9 -1
- package/types/sqlite/table/tokenizer.d.ts +11 -0
- package/types/sqlite/table/where.d.ts +5 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
import{bunMemoryBackend as
|
|
1
|
+
import{bunMemoryBackend as l}from"../table/bun-backend";import{createTable as c}from"../table/table";describe("table",()=>{let i=l(),t,r;beforeEach(async()=>{i=l(),t=await c({backend:i,tableName:"TestTable",key:"name"}),r=await c({backend:i,tableName:"TestTableNested",key:"info.name",indexes:["info.age","info.city"]})}),it("should set and get items",async()=>{const e=await t.set({name:"Alice",age:30,city:"Paris"});expect(e.key).toBe("Alice"),expect(e.op).toBe("insert");const o=await t.get("Alice");expect(o).toEqual({name:"Alice",age:30,city:"Paris"});const a=await t.set({name:"Alice",age:31,city:"Paris"});expect(a.key).toBe("Alice"),expect(a.op).toBe("update");const n=await t.get("Alice");expect(n).toEqual({name:"Alice",age:31,city:"Paris"})}),it("should set and get nested key",async()=>{const e=await r.set({info:{name:"Bob",age:25,city:"London"}});expect(e.key).toBe("Bob"),expect(e.op).toBe("insert");const o=await r.get("Bob");expect(o).toEqual({info:{name:"Bob",age:25,city:"London"}});const a=await r.set({info:{name:"Bob",age:26,city:"London"}});expect(a.key).toBe("Bob"),expect(a.op).toBe("update");const n=await r.get("Bob");expect(n).toEqual({info:{name:"Bob",age:26,city:"London"}});const s=[];for await(const d of r.search({where:{info:{city:{like:"London"}}}}))s.push(d);expect(s.length).toBe(1)}),it("should count items and count with where",async()=>{await t.set({name:"Alice",age:30,city:"Paris"}),await t.set({name:"Bob",age:25,city:"London"}),expect(await t.count()).toBe(2),expect(await t.count({where:{city:"Paris"}})).toBe(1)}),it("should search with ordering, limit and offset",async()=>{const e=[{name:"Alice",age:30,city:"Paris"},{name:"Bob",age:25,city:"London"},{name:"Carol",age:35,city:"Berlin"}];for(const s of e)await t.set(s);const o=[];for await(const s of t.search({sortBy:"age",order:"asc"}))o.push(s);expect(o.map(s=>s.name)).toEqual(["Bob","Alice","Carol"]);const a=[];for await(const s of t.search({sortBy:"age",order:"asc",limit:2}))a.push(s);expect(a.map(s=>s.name)).toEqual(["Bob","Alice"]);const n=[];for await(const s of t.search({sortBy:"age",order:"asc",offset:1,limit:2}))n.push(s);expect(n.map(s=>s.name)).toEqual(["Alice","Carol"])}),it("should deleteBy where clause",async()=>{await t.set({name:"Dave",age:40,city:"NY"}),await t.set({name:"Eve",age:45,city:"NY"}),await t.set({name:"Frank",age:50,city:"LA"}),expect(await t.count()).toBe(3),await t.deleteBy({city:"NY"}),expect(await t.count()).toBe(1),expect(await t.get("Frank")).toEqual({name:"Frank",age:50,city:"LA"}),expect(await t.get("Dave")).toBeUndefined()}),it("should use selector in get and search",async()=>{await t.set({name:"Gary",age:60,city:"SF"});const e=await t.get("Gary",({age:a})=>a);expect(e).toBe(60);const o=[];for await(const a of t.search({select:({city:n})=>n}))o.push(a);expect(o).toEqual(["SF"])}),it("should delete items by key",async()=>{await t.set({name:"Helen",age:28,city:"Rome"}),expect(await t.get("Helen")).toBeDefined(),await t.delete("Helen"),expect(await t.get("Helen")).toBeUndefined()}),it("should test search with 1000 items",async()=>{const e=[];for(let a=0;a<1e3;a++)e.push({name:`Person${a}`,age:Math.floor(Math.random()*100),city:"City"+a%10});for(const a of e)await t.set(a);const o=[];for await(const a of t.search({sortBy:"age",order:"asc",limit:100}))o.push(a);expect(o.length).toBe(100)}),it("should handle operations on an empty table",async()=>{expect(await t.count()).toBe(0),expect(await t.get("NonExistent")).toBeUndefined();const e=[];for await(const o of t.search({sortBy:"age",order:"asc"}))e.push(o);expect(e.length).toBe(0)}),it("should handle duplicate keys gracefully",async()=>{await t.set({name:"Alice",age:30,city:"Paris"}),await t.set({name:"Alice",age:35,city:"Berlin"});const e=await t.get("Alice");expect(e).toEqual({name:"Alice",age:35,city:"Berlin"})}),it("should handle edge cases in selectors",async()=>{await t.set({name:"Charlie",age:40,city:"NY"});const e=await t.get("Charlie",()=>null);expect(e).toBeNull();const o=await t.get("Charlie",()=>{});expect(o).toBeUndefined()}),it("should clear the table",async()=>{await t.set({name:"Alice",age:30,city:"Paris"}),await t.set({name:"Bob",age:25,city:"London"}),expect(await t.count()).toBe(2),await t.clear(),expect(await t.count()).toBe(0)}),it("should use fts index",async()=>{const e=await c({backend:i,tableName:"TestTableFTS",key:"id",indexes:["fts:content"]});await e.set({id:"1",content:"The \u010Coho brown fox"}),await e.set({id:"2",content:"jumps over the lazy dog"}),await e.set({id:"3",content:"hello world"});const o=[];for await(const n of e.search({where:{content:{fts:["coho","fox"]}}}))o.push(n);expect(o.length).toBe(1),expect(o[0].id).toBe("1");const a=[];for await(const n of e.search({where:{content:{fts:["the"]}}}))a.push(n);expect(a.length).toBe(2)}),it("should use fts index with custom tokenizer options",async()=>{const e=await c({backend:i,tableName:"TestTableFTS2",key:"id",indexes:[{type:"fts",path:"content",tokenizer:{removeDiacritics:0,tokenChars:"abc",separators:"xyz"}}]});await e.set({id:"1",content:"abc xyz"}),await e.set({id:"2",content:"abc"}),await e.set({id:"3",content:"other"});const o=[];for await(const n of e.search({where:{content:{fts:["abc"]}}}))o.push(n);expect(o.length).toBe(2);const a=[];for await(const n of e.search({where:{content:{fts:["xyz"]}}}))a.push(n);expect(a.length).toBe(1),expect(a[0].id).toBe("1")}),it("should support multiple fts fields",async()=>{const e=await c({backend:i,tableName:"TestTableFTSMulti",key:"id",indexes:[{type:"fts",path:"title"},{type:"fts",path:"body"}]});await e.set({id:"1",title:"Hello",body:"World"}),await e.set({id:"2",title:"Foo",body:"Bar"}),await e.set({id:"3",title:"Hello",body:"Bar"});const o=[];for await(const n of e.search({where:{title:{fts:["Hello"]}}}))o.push(n);expect(o.length).toBe(2);const a=[];for await(const n of e.search({where:{body:{fts:["Bar"]}}}))a.push(n);expect(a.length).toBe(2)}),it("should handle fts search with no results",async()=>{const e=await c({backend:i,tableName:"TestTableFTSNone",key:"id",indexes:["fts:content"]});await e.set({id:"1",content:"foo bar"});const o=[];for await(const a of e.search({where:{content:{fts:["notfound"]}}}))o.push(a);expect(o.length).toBe(0)}),it("should custom fn fts index",async()=>{const e=await c({backend:i,tableName:"TestTableFTS",key:"id",indexes:[{type:"fts",path:"content",tokenizer:{removeDiacritics:1}}]});await e.set({id:"1",content:"The \u010Coho brown fox"}),await e.set({id:"2",content:"jumps over the lazy dog"}),await e.set({id:"3",content:"hello world"});const o=[];for await(const n of e.search({where:{content:{fts:["coho","fox"]}}}))o.push(n);expect(o.length).toBe(1),expect(o[0].id).toBe("1");const a=[];for await(const n of e.search({where:{content:{fts:["the"]}}}))a.push(n);expect(a.length).toBe(2)}),it("should test fts index with nested fields",async()=>{const e=await c({backend:i,tableName:"TestTableFTSNested",key:"id",indexes:["fts:info.content"]});await e.set({id:"1",info:{content:"The quick brown fox"}}),await e.set({id:"2",info:{content:"jumps over the lazy dog"}}),await e.set({id:"3",info:{content:"hello world"}});const o=[];for await(const n of e.search({where:{info:{content:{fts:["quick","fox"]}}}}))o.push(n);expect(o.length).toBe(1),expect(o[0].id).toBe("1");const a=[];for await(const n of e.search({where:{info:{content:{fts:["the"]}}}}))a.push(n);expect(a.length).toBe(2)})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{unicodeTokenizer as t}from"../table/tokenizer";describe("tokenizer",()=>{const o=[{options:{},expected:'"unicode61"'},{options:{removeDiacritics:1},expected:'"unicode61", "remove_diacritics=1"'},{options:{tokenChars:"abc"},expected:`"unicode61", "tokenchars='abc'"`},{options:{separators:"xyz"},expected:`"unicode61", "separators='xyz'"`},{options:{removeDiacritics:2,tokenChars:"a-b",separators:"x,y"},expected:`"unicode61", "remove_diacritics=2", "tokenchars='a-b'", "separators='x,y'"`},{options:{tokenChars:"a'b",separators:"c'd"},expected:`"unicode61", "tokenchars='a''b'", "separators='c''d'"`},{options:{removeDiacritics:0,tokenChars:"",separators:""},expected:'"unicode61", "remove_diacritics=0"'},{options:{removeDiacritics:1,tokenChars:"abc",separators:"xyz"},expected:`"unicode61", "remove_diacritics=1", "tokenchars='abc'", "separators='xyz'"`}];for(const e of o)it(`returns expected tokenizer string for options: ${JSON.stringify(e.options)}`,()=>{expect(t(e.options)).toBe(e.expected)})});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./backend";export*from"./table.types";export*from"./where";export*from"./table";export*from"./map-deque";
|
|
1
|
+
export*from"./backend";export*from"./table.types";export*from"./where";export*from"./table";export*from"./map-deque";export*from"./tokenizer";
|
|
@@ -1,11 +1,38 @@
|
|
|
1
|
-
import{getWhereQuery as
|
|
2
|
-
CREATE TABLE IF NOT EXISTS ${
|
|
1
|
+
import{unicodeTokenizer as _}from"./tokenizer";import{getWhereQuery as h}from"./where";const D=500,C=100;function R(l){return"$."+l}function M(l,a){if(!(!l||!a))return a.split(".").reduce((t,f)=>{if(typeof t=="object"&&t!==null&&f in t)return t[f]},l)}async function z(l){const{backend:a,tableName:t,indexes:f,key:$,disablePragmaOptimization:I}=l,d=$!==void 0;I||(await a.execute("PRAGMA journal_mode=WAL;"),await a.execute("PRAGMA synchronous=NORMAL;"),await a.execute("PRAGMA temp_store=MEMORY;"),await a.execute("PRAGMA cache_size=-20000;")),d?await a.execute(`
|
|
2
|
+
CREATE TABLE IF NOT EXISTS ${t} (
|
|
3
3
|
key TEXT PRIMARY KEY,
|
|
4
4
|
data TEXT NOT NULL
|
|
5
5
|
);
|
|
6
|
-
`):await
|
|
7
|
-
CREATE TABLE IF NOT EXISTS ${
|
|
6
|
+
`):await a.execute(`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS ${t} (
|
|
8
8
|
data TEXT NOT NULL
|
|
9
9
|
);
|
|
10
|
-
`);for(const
|
|
11
|
-
ON ${
|
|
10
|
+
`);let E;const T=[],y={};for(const e of f??[])if(typeof e=="string"&&e.startsWith("fts:")){const n=e.slice(4),r=n.replaceAll(".","_");T.push(n),y[n]=r}else if(typeof e=="object"&&e.type==="fts"){const n=e.path,r=n.replaceAll(".","_");if(T.push(n),y[n]=r,e.tokenizer){if(!E)E=e.tokenizer;else if(E!==e.tokenizer)throw new Error(`Conflicting FTS tokenizers: already using "${E}", got "${e.tokenizer}"`)}}else{const n=String(e);await a.execute(`CREATE INDEX IF NOT EXISTS idx_${t}_${n.replaceAll(/\W/g,"_")}
|
|
11
|
+
ON ${t} (json_extract(data, '${R(n)}'));`)}if(T.length>0){let e;typeof E=="object"?e=_(E):E===void 0?e='"unicode61", "remove_diacritics=1"':e=E;const n=T.map(o=>y[o]).join(", "),r=`
|
|
12
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS ${t}_fts
|
|
13
|
+
USING fts5(${n}, tokenize=${e});
|
|
14
|
+
`;await a.execute(r),await a.execute(`
|
|
15
|
+
CREATE TRIGGER IF NOT EXISTS ${t}_ai
|
|
16
|
+
AFTER INSERT ON ${t}
|
|
17
|
+
BEGIN
|
|
18
|
+
INSERT INTO ${t}_fts(rowid, ${n})
|
|
19
|
+
VALUES (
|
|
20
|
+
new.rowid,
|
|
21
|
+
${T.map(o=>`json_extract(new.data, '${R(o)}')`).join(", ")}
|
|
22
|
+
);
|
|
23
|
+
END;
|
|
24
|
+
`),await a.execute(`
|
|
25
|
+
CREATE TRIGGER IF NOT EXISTS ${t}_ad
|
|
26
|
+
AFTER DELETE ON ${t}
|
|
27
|
+
BEGIN
|
|
28
|
+
DELETE FROM ${t}_fts WHERE rowid = old.rowid;
|
|
29
|
+
END;
|
|
30
|
+
`),await a.execute(`
|
|
31
|
+
CREATE TRIGGER IF NOT EXISTS ${t}_au
|
|
32
|
+
AFTER UPDATE ON ${t}
|
|
33
|
+
BEGIN
|
|
34
|
+
UPDATE ${t}_fts
|
|
35
|
+
SET ${T.map(o=>`${y[o]}=json_extract(new.data, '${R(o)}')`).join(", ")}
|
|
36
|
+
WHERE rowid = old.rowid;
|
|
37
|
+
END;
|
|
38
|
+
`)}function N(e){if(d)return M(e,String($))}async function L(e){return(await e.select("SELECT changes() AS c"))[0]?.c??0}const O={backend:a,async set(e,n){const r=n??a,o=JSON.stringify(e);if(d){const s=N(e);if(s==null)throw new Error(`Document is missing the configured key "${String($)}".`);if(await r.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[o,s]),await L(r)===1)return{key:s,op:"update"};try{return await r.execute(`INSERT INTO ${t} (key, data) VALUES (?, ?)`,[s,o]),{key:s,op:"insert"}}catch{return await r.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[o,s]),{key:s,op:"update"}}}await r.execute(`INSERT INTO ${t} (data) VALUES (?)`,[o]);const u=(await r.select("SELECT last_insert_rowid() AS id"))[0]?.id;if(typeof u!="number")throw new Error("Failed to retrieve last_insert_rowid()");return{key:u,op:"insert"}},async get(e,n=r=>r){const r=d?"key = ?":"rowid = ?",o=await a.select(`SELECT rowid, data FROM ${t} WHERE ${r}`,[e]);if(o.length===0)return;const{data:c,rowid:u}=o[0],s=JSON.parse(c);return n(s,{rowId:u})},async delete(e){const n=d?"key = ?":"rowid = ?";if(await a.execute(`DELETE FROM ${t} WHERE ${n}`,[e]),((await a.select("SELECT changes() AS c"))[0]?.c??0)>0)return{key:e,op:"delete"}},async*search(e={}){const{sortBy:n,order:r="asc",limit:o,offset:c=0,where:u,select:s=w=>w,stepSize:i=C}=e,p=h(u,t),A=`SELECT rowid, data FROM ${t} ${p}`;let S=0,k=c;for(;;){let w=A;n?w+=` ORDER BY json_extract(data, '${R(String(n))}') COLLATE NOCASE ${r.toUpperCase()}`:w+=d?` ORDER BY key COLLATE NOCASE ${r.toUpperCase()}`:` ORDER BY rowid ${r.toUpperCase()}`;const g=o?Math.min(i,o-S):i;w+=` LIMIT ${g} OFFSET ${k}`;const m=await a.select(w);if(m.length===0)break;for(const{rowid:x,data:b}of m){if(o&&S>=o)return;const F=JSON.parse(b);yield s(F,{rowId:x}),S++}if(m.length<g||o&&S>=o)break;k+=m.length}},async count(e={}){const n=h(e.where,t),r=`SELECT COUNT(*) as count FROM ${t} ${n}`;return(await a.select(r))[0]?.count??0},async deleteBy(e){const n=h(e,t),r=d?"key":"rowid",o=[];return await a.transaction(async c=>{const u=await c.select(`SELECT ${r} AS k FROM ${t} ${n}`);if(u.length===0)return;const s=u.map(i=>i.k);for(let i=0;i<s.length;i+=D){const p=s.slice(i,i+D),A=p.map(()=>"?").join(",");await c.execute(`DELETE FROM ${t} WHERE ${r} IN (${A})`,p)}for(const i of s)o.push({key:i,op:"delete"})}),o},async clear(){await a.execute(`DELETE FROM ${t}`)},async batchSet(e){const n=[];return await a.transaction(async r=>{for(const o of e){const c=await O.set(o,r);n.push(c)}}),n}};return O}export{C as DEFAULT_STEP_SIZE,z as createTable,M as getByPath,R as toJsonPath};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function o(i={}){const{removeDiacritics:t,tokenChars:r,separators:n}=i,e=[];return t!==void 0&&e.push(`"remove_diacritics=${t}"`),r&&r.length>0&&e.push(`"tokenchars='${r.replaceAll("'","''")}'"`),n&&n.length>0&&e.push(`"separators='${n.replaceAll("'","''")}'"`),e.length===0||!(e.length>0)?'"unicode61"':`"unicode61", ${e.join(", ")}`}export{o as unicodeTokenizer};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
function
|
|
1
|
+
function a(n){return typeof n=="string"?`'${n.replaceAll("'","''")}'`:typeof n=="number"?n.toString():typeof n=="boolean"?n?"1":"0":`'${String(n).replaceAll("'","''")}'`}function A(n,r,t){const e=t?`${t}.`:"";return n==="KEY"?`"${e}key"`:typeof r=="string"?`CAST(json_extract(${e}data, '$.${n}') AS TEXT)`:typeof r=="number"?`CAST(json_extract(${e}data, '$.${n}') AS NUMERIC)`:typeof r=="boolean"?`CAST(json_extract(${e}data, '$.${n}') AS INTEGER)`:`json_extract(${e}data, '$.${n}')`}const N=new Set(["is","isNot","gt","gte","lt","lte","in","notIn","like","fts"]);function p(n,r=""){const t={};for(const[e,c]of Object.entries(n)){if(e==="AND"||e==="OR"||e==="NOT"){t[e]=c;continue}const $=r?`${r}.${e}`:e;c&&typeof c=="object"&&!Array.isArray(c)&&!Object.keys(c).some(i=>N.has(i))?Object.assign(t,p(c,$)):t[$]=c}return t}function l(n,r,t){if(!n||typeof n!="object")return"";if(n.AND){const i=Array.isArray(n.AND)?n.AND.map(u=>l(u,r,t)).filter(Boolean):[];return i.length>0?`(${i.join(" AND ")})`:""}if(n.OR){const i=Array.isArray(n.OR)?n.OR.map(u=>l(u,r,t)).filter(Boolean):[];return i.length>0?`(${i.join(" OR ")})`:""}if(n.NOT){const i=l(n.NOT,r,t);return i?`(NOT ${i})`:""}const e=p(n);let c="",$=!1;for(const[i,u]of Object.entries(e)){if(u==null)continue;let T;typeof u!="object"||Array.isArray(u)?T=Array.isArray(u)?{in:u}:{is:u}:T=u;for(const s of Object.keys(T)){const d=T[s];if(d==null)continue;const y=Array.isArray(d)?d:[d];if(y.length!==0){if(s==="fts"){if(!t)throw new Error("FTS requires tableName for JOIN reference");const o=y.map(f=>`EXISTS (SELECT 1 FROM ${t}_fts f WHERE f.rowid = ${r??t}.rowid AND ${t}_fts MATCH ${a(f)})`).join(" AND ");c+=($?" AND ":"")+o,$=!0;continue}if(s==="is"||s==="isNot"||s==="in"||s==="notIn"){const o=A(i,y[0],r),f=y.map(a).join(","),g=s==="is"?y.length>1?`${o} IN (${f})`:`${o} = ${a(y[0])}`:s==="isNot"?y.length>1?`${o} NOT IN (${f})`:`${o} <> ${a(y[0])}`:s==="in"?`${o} IN (${f})`:`${o} NOT IN (${f})`;c+=($?" AND ":"")+g,$=!0;continue}for(const o of y){const f=A(i,o,r),g=s==="gt"?`${f} > ${a(o)}`:s==="gte"?`${f} >= ${a(o)}`:s==="lt"?`${f} < ${a(o)}`:s==="lte"?`${f} <= ${a(o)}`:`${f} LIKE ${a(o)}`;c+=($?" AND ":"")+g,$=!0}}}}return $?`(${c})`:""}function k(n,r){if(!n)return"";const t=l(n,void 0,r);return t?`WHERE ${t}`:""}export{l as getWhere,k as getWhereQuery};
|
package/package.json
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-shadow */
|
|
2
2
|
/* eslint-disable no-shadow */
|
|
3
3
|
/* eslint-disable sonarjs/pseudo-random */
|
|
4
|
-
// /* eslint-disable sonarjs/no-unused-vars */
|
|
5
|
-
// /* eslint-disable unicorn/prevent-abbreviations */
|
|
6
|
-
|
|
7
4
|
import { bunMemoryBackend } from '../table/bun-backend'
|
|
8
5
|
import { createTable } from '../table/table'
|
|
9
6
|
|
|
@@ -178,4 +175,177 @@ describe('table', () => {
|
|
|
178
175
|
const undefinedSelector = await table.get('Charlie', () => void 0)
|
|
179
176
|
expect(undefinedSelector).toBeUndefined()
|
|
180
177
|
})
|
|
178
|
+
|
|
179
|
+
it('should clear the table', async () => {
|
|
180
|
+
await table.set({ name: 'Alice', age: 30, city: 'Paris' })
|
|
181
|
+
await table.set({ name: 'Bob', age: 25, city: 'London' })
|
|
182
|
+
expect(await table.count()).toBe(2)
|
|
183
|
+
await table.clear()
|
|
184
|
+
expect(await table.count()).toBe(0)
|
|
185
|
+
})
|
|
186
|
+
it('should use fts index', async () => {
|
|
187
|
+
const tableFts = await createTable<{ id: string; content: string }>({
|
|
188
|
+
backend,
|
|
189
|
+
tableName: 'TestTableFTS',
|
|
190
|
+
key: 'id',
|
|
191
|
+
indexes: ['fts:content'],
|
|
192
|
+
})
|
|
193
|
+
await tableFts.set({ id: '1', content: 'The Čoho brown fox' })
|
|
194
|
+
await tableFts.set({ id: '2', content: 'jumps over the lazy dog' })
|
|
195
|
+
await tableFts.set({ id: '3', content: 'hello world' })
|
|
196
|
+
|
|
197
|
+
const results: { id: string; content: string }[] = []
|
|
198
|
+
for await (const doc of tableFts.search({ where: { content: { fts: ['coho', 'fox'] } } })) {
|
|
199
|
+
results.push(doc)
|
|
200
|
+
}
|
|
201
|
+
expect(results.length).toBe(1)
|
|
202
|
+
expect(results[0].id).toBe('1')
|
|
203
|
+
|
|
204
|
+
const results2: { id: string; content: string }[] = []
|
|
205
|
+
for await (const doc of tableFts.search({ where: { content: { fts: ['the'] } } })) {
|
|
206
|
+
results2.push(doc)
|
|
207
|
+
}
|
|
208
|
+
expect(results2.length).toBe(2)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should use fts index with custom tokenizer options', async () => {
|
|
212
|
+
// Use only ASCII letters for tokenchars/separators to avoid SQLite errors
|
|
213
|
+
const tableFts = await createTable<{ id: string; content: string }>({
|
|
214
|
+
backend,
|
|
215
|
+
tableName: 'TestTableFTS2',
|
|
216
|
+
key: 'id',
|
|
217
|
+
indexes: [
|
|
218
|
+
{
|
|
219
|
+
type: 'fts',
|
|
220
|
+
path: 'content',
|
|
221
|
+
tokenizer: { removeDiacritics: 0, tokenChars: 'abc', separators: 'xyz' },
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
})
|
|
225
|
+
await tableFts.set({ id: '1', content: 'abc xyz' })
|
|
226
|
+
await tableFts.set({ id: '2', content: 'abc' })
|
|
227
|
+
await tableFts.set({ id: '3', content: 'other' })
|
|
228
|
+
|
|
229
|
+
const results: { id: string; content: string }[] = []
|
|
230
|
+
for await (const doc of tableFts.search({ where: { content: { fts: ['abc'] } } })) {
|
|
231
|
+
results.push(doc)
|
|
232
|
+
}
|
|
233
|
+
expect(results.length).toBe(2)
|
|
234
|
+
|
|
235
|
+
const results2: { id: string; content: string }[] = []
|
|
236
|
+
for await (const doc of tableFts.search({ where: { content: { fts: ['xyz'] } } })) {
|
|
237
|
+
results2.push(doc)
|
|
238
|
+
}
|
|
239
|
+
expect(results2.length).toBe(1)
|
|
240
|
+
expect(results2[0].id).toBe('1')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should support multiple fts fields', async () => {
|
|
244
|
+
// Use a single FTS index with multiple fields (title, body) in one index
|
|
245
|
+
const tableFts = await createTable<{ id: string; title: string; body: string }>({
|
|
246
|
+
backend,
|
|
247
|
+
tableName: 'TestTableFTSMulti',
|
|
248
|
+
key: 'id',
|
|
249
|
+
indexes: [
|
|
250
|
+
{ type: 'fts', path: 'title' },
|
|
251
|
+
{ type: 'fts', path: 'body' },
|
|
252
|
+
],
|
|
253
|
+
})
|
|
254
|
+
await tableFts.set({ id: '1', title: 'Hello', body: 'World' })
|
|
255
|
+
await tableFts.set({ id: '2', title: 'Foo', body: 'Bar' })
|
|
256
|
+
await tableFts.set({ id: '3', title: 'Hello', body: 'Bar' })
|
|
257
|
+
|
|
258
|
+
// FTS search on title
|
|
259
|
+
const results: { id: string; title: string; body: string }[] = []
|
|
260
|
+
for await (const doc of tableFts.search({ where: { title: { fts: ['Hello'] } } })) {
|
|
261
|
+
results.push(doc)
|
|
262
|
+
}
|
|
263
|
+
expect(results.length).toBe(2)
|
|
264
|
+
|
|
265
|
+
// FTS search on body
|
|
266
|
+
const results2: { id: string; title: string; body: string }[] = []
|
|
267
|
+
for await (const doc of tableFts.search({ where: { body: { fts: ['Bar'] } } })) {
|
|
268
|
+
results2.push(doc)
|
|
269
|
+
}
|
|
270
|
+
expect(results2.length).toBe(2)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('should handle fts search with no results', async () => {
|
|
274
|
+
const tableFts = await createTable<{ id: string; content: string }>({
|
|
275
|
+
backend,
|
|
276
|
+
tableName: 'TestTableFTSNone',
|
|
277
|
+
key: 'id',
|
|
278
|
+
indexes: ['fts:content'],
|
|
279
|
+
})
|
|
280
|
+
await tableFts.set({ id: '1', content: 'foo bar' })
|
|
281
|
+
const results: { id: string; content: string }[] = []
|
|
282
|
+
for await (const doc of tableFts.search({ where: { content: { fts: ['notfound'] } } })) {
|
|
283
|
+
results.push(doc)
|
|
284
|
+
}
|
|
285
|
+
expect(results.length).toBe(0)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should custom fn fts index', async () => {
|
|
289
|
+
const tableFts = await createTable<{ id: string; content: string }>({
|
|
290
|
+
backend,
|
|
291
|
+
tableName: 'TestTableFTS',
|
|
292
|
+
key: 'id',
|
|
293
|
+
indexes: [
|
|
294
|
+
{
|
|
295
|
+
type: 'fts',
|
|
296
|
+
path: 'content',
|
|
297
|
+
tokenizer: {
|
|
298
|
+
removeDiacritics: 1,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
})
|
|
303
|
+
await tableFts.set({ id: '1', content: 'The Čoho brown fox' })
|
|
304
|
+
await tableFts.set({ id: '2', content: 'jumps over the lazy dog' })
|
|
305
|
+
await tableFts.set({ id: '3', content: 'hello world' })
|
|
306
|
+
|
|
307
|
+
const results: { id: string; content: string }[] = []
|
|
308
|
+
for await (const doc of tableFts.search({ where: { content: { fts: ['coho', 'fox'] } } })) {
|
|
309
|
+
results.push(doc)
|
|
310
|
+
}
|
|
311
|
+
expect(results.length).toBe(1)
|
|
312
|
+
expect(results[0].id).toBe('1')
|
|
313
|
+
|
|
314
|
+
const results2: { id: string; content: string }[] = []
|
|
315
|
+
for await (const doc of tableFts.search({ where: { content: { fts: ['the'] } } })) {
|
|
316
|
+
results2.push(doc)
|
|
317
|
+
}
|
|
318
|
+
expect(results2.length).toBe(2)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('should test fts index with nested fields', async () => {
|
|
322
|
+
const tableFts = await createTable<{ id: string; info: { content: string } }>({
|
|
323
|
+
backend,
|
|
324
|
+
tableName: 'TestTableFTSNested',
|
|
325
|
+
key: 'id',
|
|
326
|
+
indexes: ['fts:info.content'],
|
|
327
|
+
})
|
|
328
|
+
await tableFts.set({ id: '1', info: { content: 'The quick brown fox' } })
|
|
329
|
+
await tableFts.set({ id: '2', info: { content: 'jumps over the lazy dog' } })
|
|
330
|
+
await tableFts.set({ id: '3', info: { content: 'hello world' } })
|
|
331
|
+
|
|
332
|
+
const results: { id: string; info: { content: string } }[] = []
|
|
333
|
+
for await (const doc of tableFts.search({
|
|
334
|
+
where: {
|
|
335
|
+
info: {
|
|
336
|
+
content: { fts: ['quick', 'fox'] },
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
})) {
|
|
340
|
+
results.push(doc)
|
|
341
|
+
}
|
|
342
|
+
expect(results.length).toBe(1)
|
|
343
|
+
expect(results[0].id).toBe('1')
|
|
344
|
+
|
|
345
|
+
const results2: { id: string; info: { content: string } }[] = []
|
|
346
|
+
for await (const doc of tableFts.search({ where: { info: { content: { fts: ['the'] } } } })) {
|
|
347
|
+
results2.push(doc)
|
|
348
|
+
}
|
|
349
|
+
expect(results2.length).toBe(2)
|
|
350
|
+
})
|
|
181
351
|
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { unicodeTokenizer, type FtsTokenizerOptions } from '../table/tokenizer'
|
|
2
|
+
|
|
3
|
+
describe('tokenizer', () => {
|
|
4
|
+
const items: {
|
|
5
|
+
options: FtsTokenizerOptions
|
|
6
|
+
expected: string
|
|
7
|
+
}[] = [
|
|
8
|
+
{ options: {}, expected: '"unicode61"' },
|
|
9
|
+
{
|
|
10
|
+
options: { removeDiacritics: 1 },
|
|
11
|
+
expected: '"unicode61", "remove_diacritics=1"',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
options: { tokenChars: 'abc' },
|
|
15
|
+
expected: '"unicode61", "tokenchars=\'abc\'"',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
options: { separators: 'xyz' },
|
|
19
|
+
expected: '"unicode61", "separators=\'xyz\'"',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
options: { removeDiacritics: 2, tokenChars: 'a-b', separators: 'x,y' },
|
|
23
|
+
expected: '"unicode61", "remove_diacritics=2", "tokenchars=\'a-b\'", "separators=\'x,y\'"',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
options: { tokenChars: "a'b", separators: "c'd" },
|
|
27
|
+
expected: "\"unicode61\", \"tokenchars='a''b'\", \"separators='c''d'\"",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
options: { removeDiacritics: 0, tokenChars: '', separators: '' },
|
|
31
|
+
expected: '"unicode61", "remove_diacritics=0"',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
options: { removeDiacritics: 1, tokenChars: 'abc', separators: 'xyz' },
|
|
35
|
+
expected: '"unicode61", "remove_diacritics=1", "tokenchars=\'abc\'", "separators=\'xyz\'"',
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
for (const item of items) {
|
|
39
|
+
it(`returns expected tokenizer string for options: ${JSON.stringify(item.options)}`, () => {
|
|
40
|
+
expect(unicodeTokenizer(item.options)).toBe(item.expected)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
/* eslint-disable sonarjs/cognitive-complexity */
|
|
4
4
|
/* eslint-disable @typescript-eslint/no-shadow */
|
|
5
5
|
/* eslint-disable no-shadow */
|
|
6
|
-
|
|
7
6
|
import type { Table, DbOptions, DocType, Key, SearchOptions, MutationResult } from './table.types'
|
|
7
|
+
import { unicodeTokenizer, type FtsTokenizerOptions } from './tokenizer'
|
|
8
8
|
import type { Where } from './where'
|
|
9
9
|
import { getWhereQuery } from './where'
|
|
10
10
|
|
|
11
|
-
const DELETE_IN_CHUNK = 500
|
|
11
|
+
const DELETE_IN_CHUNK = 500
|
|
12
12
|
export const DEFAULT_STEP_SIZE = 100
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Convert a dot-separated path to a JSON path
|
|
16
|
-
* @param dot The dot-separated path
|
|
17
|
-
* @returns The JSON path
|
|
16
|
+
* @param dot The dot-separated path string
|
|
17
|
+
* @returns The JSON path string
|
|
18
18
|
*/
|
|
19
19
|
export function toJsonPath(dot: string) {
|
|
20
20
|
return '$.' + dot
|
|
@@ -22,8 +22,8 @@ export function toJsonPath(dot: string) {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Get a nested value from an object using a dot-separated path
|
|
25
|
-
* @param object The object to
|
|
26
|
-
* @param path The dot-separated path
|
|
25
|
+
* @param object The object to retrieve the value from
|
|
26
|
+
* @param path The dot-separated path string
|
|
27
27
|
* @returns The value at the specified path, or undefined if not found
|
|
28
28
|
*/
|
|
29
29
|
export function getByPath<T extends object>(object: T, path: string): unknown {
|
|
@@ -38,15 +38,14 @@ export function getByPath<T extends object>(object: T, path: string): unknown {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Create a
|
|
42
|
-
* @param options The options for creating the table
|
|
43
|
-
* @returns
|
|
41
|
+
* Create and initialize a table in the database with the specified options
|
|
42
|
+
* @param options The options for creating the table, including table name, indexes, backend, and key
|
|
43
|
+
* @returns A promise that resolves to the created Table instance
|
|
44
44
|
*/
|
|
45
45
|
export async function createTable<Document extends DocType>(options: DbOptions<Document>): Promise<Table<Document>> {
|
|
46
46
|
const { backend, tableName, indexes, key, disablePragmaOptimization } = options
|
|
47
47
|
const hasUserKey = key !== undefined
|
|
48
48
|
|
|
49
|
-
// --- Apply performance PRAGMAs unless explicitly disabled ---
|
|
50
49
|
if (!disablePragmaOptimization) {
|
|
51
50
|
await backend.execute(`PRAGMA journal_mode=WAL;`)
|
|
52
51
|
await backend.execute(`PRAGMA synchronous=NORMAL;`)
|
|
@@ -54,7 +53,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
54
53
|
await backend.execute(`PRAGMA cache_size=-20000;`)
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
//
|
|
56
|
+
// Base JSON table
|
|
58
57
|
if (hasUserKey) {
|
|
59
58
|
await backend.execute(`
|
|
60
59
|
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
@@ -70,19 +69,95 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
70
69
|
`)
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
//
|
|
72
|
+
// Track FTS fields and map dot paths to valid SQLite column names
|
|
73
|
+
let ftsTokenizer: string | undefined | FtsTokenizerOptions
|
|
74
|
+
const ftsFields: string[] = []
|
|
75
|
+
const ftsFieldMap: Record<string, string> = {} // dot path -> column name
|
|
76
|
+
|
|
74
77
|
for (const index of indexes ?? []) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
if (typeof index === 'string' && index.startsWith('fts:')) {
|
|
79
|
+
const path = index.slice(4)
|
|
80
|
+
const col = path.replaceAll('.', '_')
|
|
81
|
+
ftsFields.push(path)
|
|
82
|
+
ftsFieldMap[path] = col
|
|
83
|
+
} else if (typeof index === 'object' && index.type === 'fts') {
|
|
84
|
+
const path = index.path
|
|
85
|
+
const col = path.replaceAll('.', '_')
|
|
86
|
+
ftsFields.push(path)
|
|
87
|
+
ftsFieldMap[path] = col
|
|
88
|
+
if (index.tokenizer) {
|
|
89
|
+
if (!ftsTokenizer) {
|
|
90
|
+
ftsTokenizer = index.tokenizer
|
|
91
|
+
} else if (ftsTokenizer !== index.tokenizer) {
|
|
92
|
+
throw new Error(`Conflicting FTS tokenizers: already using "${ftsTokenizer}", got "${index.tokenizer}"`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
const idx = String(index)
|
|
97
|
+
await backend.execute(
|
|
98
|
+
`CREATE INDEX IF NOT EXISTS idx_${tableName}_${idx.replaceAll(/\W/g, '_')}
|
|
78
99
|
ON ${tableName} (json_extract(data, '${toJsonPath(idx)}'));`,
|
|
79
|
-
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Create FTS table + triggers
|
|
105
|
+
if (ftsFields.length > 0) {
|
|
106
|
+
let tokenizerSpec: string
|
|
107
|
+
if (typeof ftsTokenizer === 'object') {
|
|
108
|
+
tokenizerSpec = unicodeTokenizer(ftsTokenizer)
|
|
109
|
+
} else if (ftsTokenizer === undefined) {
|
|
110
|
+
tokenizerSpec = '"unicode61", "remove_diacritics=1"'
|
|
111
|
+
} else {
|
|
112
|
+
tokenizerSpec = ftsTokenizer
|
|
113
|
+
}
|
|
114
|
+
// Use mapped column names for FTS columns
|
|
115
|
+
const ftsColumns = ftsFields.map((f) => ftsFieldMap[f]).join(', ')
|
|
116
|
+
const query = `
|
|
117
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS ${tableName}_fts
|
|
118
|
+
USING fts5(${ftsColumns}, tokenize=${tokenizerSpec});
|
|
119
|
+
`
|
|
120
|
+
|
|
121
|
+
await backend.execute(query)
|
|
122
|
+
|
|
123
|
+
// Insert trigger
|
|
124
|
+
await backend.execute(`
|
|
125
|
+
CREATE TRIGGER IF NOT EXISTS ${tableName}_ai
|
|
126
|
+
AFTER INSERT ON ${tableName}
|
|
127
|
+
BEGIN
|
|
128
|
+
INSERT INTO ${tableName}_fts(rowid, ${ftsColumns})
|
|
129
|
+
VALUES (
|
|
130
|
+
new.rowid,
|
|
131
|
+
${ftsFields.map((f) => `json_extract(new.data, '${toJsonPath(f)}')`).join(', ')}
|
|
132
|
+
);
|
|
133
|
+
END;
|
|
134
|
+
`)
|
|
135
|
+
|
|
136
|
+
// Delete trigger
|
|
137
|
+
await backend.execute(`
|
|
138
|
+
CREATE TRIGGER IF NOT EXISTS ${tableName}_ad
|
|
139
|
+
AFTER DELETE ON ${tableName}
|
|
140
|
+
BEGIN
|
|
141
|
+
DELETE FROM ${tableName}_fts WHERE rowid = old.rowid;
|
|
142
|
+
END;
|
|
143
|
+
`)
|
|
144
|
+
|
|
145
|
+
// Update trigger
|
|
146
|
+
await backend.execute(`
|
|
147
|
+
CREATE TRIGGER IF NOT EXISTS ${tableName}_au
|
|
148
|
+
AFTER UPDATE ON ${tableName}
|
|
149
|
+
BEGIN
|
|
150
|
+
UPDATE ${tableName}_fts
|
|
151
|
+
SET ${ftsFields.map((f) => `${ftsFieldMap[f]}=json_extract(new.data, '${toJsonPath(f)}')`).join(', ')}
|
|
152
|
+
WHERE rowid = old.rowid;
|
|
153
|
+
END;
|
|
154
|
+
`)
|
|
80
155
|
}
|
|
81
156
|
|
|
82
157
|
/**
|
|
83
|
-
* Get the key
|
|
158
|
+
* Get the value of the configured key from a document
|
|
84
159
|
* @param document The document to extract the key from
|
|
85
|
-
* @returns The key
|
|
160
|
+
* @returns The value of the key, or undefined if not found or no key is configured
|
|
86
161
|
*/
|
|
87
162
|
function getKeyFromDocument(document: Document): Key | undefined {
|
|
88
163
|
if (!hasUserKey) return undefined
|
|
@@ -90,9 +165,9 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
90
165
|
}
|
|
91
166
|
|
|
92
167
|
/**
|
|
93
|
-
* Get the number of rows changed by the last operation
|
|
94
|
-
* @param conn The database connection
|
|
95
|
-
* @returns
|
|
168
|
+
* Get the number of rows changed by the last operation on the given connection
|
|
169
|
+
* @param conn The database connection to check for changes
|
|
170
|
+
* @returns A promise that resolves to the number of changed rows
|
|
96
171
|
*/
|
|
97
172
|
async function getChanges(conn: typeof backend): Promise<number> {
|
|
98
173
|
const r = await conn.select<Array<{ c: number }>>(`SELECT changes() AS c`)
|
|
@@ -109,17 +184,13 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
109
184
|
if (hasUserKey) {
|
|
110
185
|
const id = getKeyFromDocument(document)
|
|
111
186
|
if (id === undefined || id === null) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Document is missing the configured key "${String(key)}". Provide it or create the table without "key".`,
|
|
114
|
-
)
|
|
187
|
+
throw new Error(`Document is missing the configured key "${String(key)}".`)
|
|
115
188
|
}
|
|
116
189
|
|
|
117
|
-
// Fast path: UPDATE first
|
|
118
190
|
await db.execute(`UPDATE ${tableName} SET data = ? WHERE key = ?`, [json, id])
|
|
119
191
|
const updated = await getChanges(db)
|
|
120
192
|
if (updated === 1) return { key: id, op: 'update' }
|
|
121
193
|
|
|
122
|
-
// No row updated => try INSERT
|
|
123
194
|
try {
|
|
124
195
|
await db.execute(`INSERT INTO ${tableName} (key, data) VALUES (?, ?)`, [id, json])
|
|
125
196
|
return { key: id, op: 'insert' }
|
|
@@ -129,7 +200,6 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
129
200
|
}
|
|
130
201
|
}
|
|
131
202
|
|
|
132
|
-
// ROWID mode
|
|
133
203
|
await db.execute(`INSERT INTO ${tableName} (data) VALUES (?)`, [json])
|
|
134
204
|
const rows = await db.select<Array<{ id: number }>>(`SELECT last_insert_rowid() AS id`)
|
|
135
205
|
const rowid = rows[0]?.id
|
|
@@ -139,7 +209,6 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
139
209
|
|
|
140
210
|
async get<Selected = Document>(
|
|
141
211
|
keyValue: Key,
|
|
142
|
-
// keep meta available and consistent with search() { rowId }
|
|
143
212
|
selector: (document: Document, meta: { rowId: number }) => Selected = (d) => d as unknown as Selected,
|
|
144
213
|
) {
|
|
145
214
|
const whereKey = hasUserKey ? `key = ?` : `rowid = ?`
|
|
@@ -172,7 +241,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
172
241
|
stepSize = DEFAULT_STEP_SIZE,
|
|
173
242
|
} = options
|
|
174
243
|
|
|
175
|
-
const whereSql = getWhereQuery<Document>(where)
|
|
244
|
+
const whereSql = getWhereQuery<Document>(where, tableName)
|
|
176
245
|
const baseQuery = `SELECT rowid, data FROM ${tableName} ${whereSql}`
|
|
177
246
|
|
|
178
247
|
let yielded = 0
|
|
@@ -205,14 +274,14 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
205
274
|
},
|
|
206
275
|
|
|
207
276
|
async count(options: { where?: Where<Document> } = {}) {
|
|
208
|
-
const whereSql = getWhereQuery<Document>(options.where)
|
|
277
|
+
const whereSql = getWhereQuery<Document>(options.where, tableName)
|
|
209
278
|
const query = `SELECT COUNT(*) as count FROM ${tableName} ${whereSql}`
|
|
210
279
|
const result = await backend.select<Array<{ count: number }>>(query)
|
|
211
280
|
return result[0]?.count ?? 0
|
|
212
281
|
},
|
|
213
282
|
|
|
214
283
|
async deleteBy(where: Where<Document>) {
|
|
215
|
-
const whereSql = getWhereQuery<Document>(where)
|
|
284
|
+
const whereSql = getWhereQuery<Document>(where, tableName)
|
|
216
285
|
const keyCol = hasUserKey ? 'key' : 'rowid'
|
|
217
286
|
const results: MutationResult[] = []
|
|
218
287
|
|
|
@@ -236,6 +305,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
236
305
|
async clear() {
|
|
237
306
|
await backend.execute(`DELETE FROM ${tableName}`)
|
|
238
307
|
},
|
|
308
|
+
|
|
239
309
|
async batchSet(documents: Document[]) {
|
|
240
310
|
const mutations: MutationResult[] = []
|
|
241
311
|
await backend.transaction(async (tx) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// table.types.ts
|
|
2
1
|
import type { SqlSeachOptions } from '../select-sql'
|
|
3
2
|
import type { Backend } from './backend'
|
|
3
|
+
import type { FtsTokenizerOptions } from './tokenizer'
|
|
4
4
|
import type { Where } from './where'
|
|
5
5
|
|
|
6
6
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -19,10 +19,28 @@ export type DotPath<T, D extends number = 5> = [D] extends [never]
|
|
|
19
19
|
[K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K
|
|
20
20
|
}[Extract<keyof T, string>]
|
|
21
21
|
: never
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
// Built-in FTS5 tokenizers
|
|
24
|
+
export type FtsTokenizer =
|
|
25
|
+
| 'porter' // English stemming
|
|
26
|
+
| 'simple' // basic ASCII tokenizer
|
|
27
|
+
| 'icu' // requires SQLite compiled with ICU extension
|
|
28
|
+
| 'unicode61' // Unicode-aware tokenizer with diacritic removal and custom token chars
|
|
29
|
+
| FtsTokenizerOptions
|
|
30
|
+
|
|
31
|
+
export interface FtsType<Document extends DocType> {
|
|
32
|
+
readonly type: 'fts'
|
|
33
|
+
readonly path: DotPath<Document>
|
|
34
|
+
readonly tokenizer?: FtsTokenizer
|
|
35
|
+
}
|
|
36
|
+
export type IndexDeclaration<Document extends DocType> =
|
|
37
|
+
| DotPath<Document> // normal JSON path index
|
|
38
|
+
| `fts:${DotPath<Document>}` // full-text index
|
|
39
|
+
| FtsType<Document> // full-text index with options
|
|
40
|
+
|
|
23
41
|
export interface DbOptions<Document extends DocType> {
|
|
24
42
|
readonly tableName: string
|
|
25
|
-
readonly indexes?: Array<
|
|
43
|
+
readonly indexes?: Array<IndexDeclaration<Document>>
|
|
26
44
|
readonly backend: Backend
|
|
27
45
|
readonly key?: DotPath<Document>
|
|
28
46
|
readonly disablePragmaOptimization?: boolean
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface FtsTokenizerOptions {
|
|
2
|
+
readonly removeDiacritics?: 0 | 1 | 2
|
|
3
|
+
readonly tokenChars?: string
|
|
4
|
+
readonly separators?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a custom FTS5 tokenizer string based on the provided options
|
|
9
|
+
* @param options Options to customize the tokenizer
|
|
10
|
+
* @returns A string representing the FTS5 tokenizer configuration
|
|
11
|
+
*/
|
|
12
|
+
export function unicodeTokenizer(options: FtsTokenizerOptions = {}): string {
|
|
13
|
+
const { removeDiacritics, tokenChars, separators } = options
|
|
14
|
+
const parts: string[] = []
|
|
15
|
+
|
|
16
|
+
if (removeDiacritics !== undefined) {
|
|
17
|
+
parts.push(`"remove_diacritics=${removeDiacritics}"`)
|
|
18
|
+
}
|
|
19
|
+
if (tokenChars && tokenChars.length > 0) {
|
|
20
|
+
parts.push(`"tokenchars='${tokenChars.replaceAll("'", "''")}'"`)
|
|
21
|
+
}
|
|
22
|
+
if (separators && separators.length > 0) {
|
|
23
|
+
parts.push(`"separators='${separators.replaceAll("'", "''")}'"`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 👉 return correct SQLite syntax
|
|
27
|
+
if (parts.length === 0) {
|
|
28
|
+
return '"unicode61"'
|
|
29
|
+
}
|
|
30
|
+
const hasParts = parts.length > 0
|
|
31
|
+
if (!hasParts) {
|
|
32
|
+
return '"unicode61"'
|
|
33
|
+
}
|
|
34
|
+
return `"unicode61", ${parts.join(', ')}`
|
|
35
|
+
}
|
|
@@ -17,6 +17,7 @@ interface Condition<T> {
|
|
|
17
17
|
readonly in?: T[]
|
|
18
18
|
readonly notIn?: T[]
|
|
19
19
|
readonly like?: T | T[]
|
|
20
|
+
readonly fts?: string | string[] // 🔥 NEW
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
// -------------------------------------------------------------
|
|
@@ -55,16 +56,13 @@ function inlineValue(value: unknown): string {
|
|
|
55
56
|
*/
|
|
56
57
|
function getFieldExpr(field: string, value: unknown, tableAlias?: string): string {
|
|
57
58
|
const prefix = tableAlias ? `${tableAlias}.` : ''
|
|
58
|
-
if (field === 'KEY') {
|
|
59
|
-
return `"${prefix}key"`
|
|
60
|
-
}
|
|
59
|
+
if (field === 'KEY') return `"${prefix}key"`
|
|
61
60
|
if (typeof value === 'string') return `CAST(json_extract(${prefix}data, '$.${field}') AS TEXT)`
|
|
62
61
|
if (typeof value === 'number') return `CAST(json_extract(${prefix}data, '$.${field}') AS NUMERIC)`
|
|
63
62
|
if (typeof value === 'boolean') return `CAST(json_extract(${prefix}data, '$.${field}') AS INTEGER)`
|
|
64
63
|
return `json_extract(${prefix}data, '$.${field}')`
|
|
65
64
|
}
|
|
66
|
-
|
|
67
|
-
const OPS_SET: ReadonlySet<string> = new Set(['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'like'])
|
|
65
|
+
const OPS_SET: ReadonlySet<string> = new Set(['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'like', 'fts'])
|
|
68
66
|
|
|
69
67
|
/**
|
|
70
68
|
* Flatten a nested Where object into a single-level object with dot-separated keys
|
|
@@ -97,28 +95,27 @@ function flattenWhere(object: Record<string, unknown>, prefix = ''): Record<stri
|
|
|
97
95
|
* Write SQL WHERE clause from a Where object
|
|
98
96
|
* @param where The Where object defining the conditions
|
|
99
97
|
* @param tableAlias Optional table alias to prefix field names
|
|
98
|
+
* @param tableName Optional table name (required for FTS conditions)
|
|
100
99
|
* @returns The SQL WHERE clause string (without the "WHERE" keyword)
|
|
101
100
|
*/
|
|
102
|
-
export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string): string {
|
|
101
|
+
export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string, tableName?: string): string {
|
|
103
102
|
if (!where || typeof where !== 'object') return ''
|
|
104
103
|
|
|
105
|
-
// ----- Logical branches -----
|
|
106
104
|
if (where.AND) {
|
|
107
|
-
const clauses = Array.isArray(where.AND) ? where.AND.map((w) => getWhere(w, tableAlias)).filter(Boolean) : []
|
|
105
|
+
const clauses = Array.isArray(where.AND) ? where.AND.map((w) => getWhere(w, tableAlias, tableName)).filter(Boolean) : []
|
|
108
106
|
return clauses.length > 0 ? `(${clauses.join(' AND ')})` : ''
|
|
109
107
|
}
|
|
110
108
|
|
|
111
109
|
if (where.OR) {
|
|
112
|
-
const clauses = Array.isArray(where.OR) ? where.OR.map((w) => getWhere(w, tableAlias)).filter(Boolean) : []
|
|
110
|
+
const clauses = Array.isArray(where.OR) ? where.OR.map((w) => getWhere(w, tableAlias, tableName)).filter(Boolean) : []
|
|
113
111
|
return clauses.length > 0 ? `(${clauses.join(' OR ')})` : ''
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
if (where.NOT) {
|
|
117
|
-
const clause = getWhere(where.NOT, tableAlias)
|
|
115
|
+
const clause = getWhere(where.NOT, tableAlias, tableName)
|
|
118
116
|
return clause ? `(NOT ${clause})` : ''
|
|
119
117
|
}
|
|
120
118
|
|
|
121
|
-
// ----- Field conditions -----
|
|
122
119
|
const flat = flattenWhere(where as Record<string, unknown>)
|
|
123
120
|
let fieldClauses = ''
|
|
124
121
|
let anyField = false
|
|
@@ -126,7 +123,6 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
|
|
|
126
123
|
for (const [key, rawValue] of Object.entries(flat)) {
|
|
127
124
|
if (rawValue == null) continue
|
|
128
125
|
|
|
129
|
-
// coerce primitive/array into Condition
|
|
130
126
|
let cond: Condition<unknown>
|
|
131
127
|
if (typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
|
132
128
|
cond = Array.isArray(rawValue) ? { in: rawValue } : { is: rawValue }
|
|
@@ -135,18 +131,28 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
|
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
for (const opKey of Object.keys(cond)) {
|
|
138
|
-
if (!OPS_SET.has(opKey)) continue
|
|
139
134
|
const opValue = cond[opKey as keyof Condition<unknown>]
|
|
140
135
|
if (opValue == null) continue
|
|
141
136
|
|
|
142
137
|
const values = Array.isArray(opValue) ? opValue : [opValue]
|
|
143
138
|
if (values.length === 0) continue
|
|
144
139
|
|
|
145
|
-
|
|
140
|
+
if (opKey === 'fts') {
|
|
141
|
+
if (!tableName) throw new Error('FTS requires tableName for JOIN reference')
|
|
142
|
+
const clause = values
|
|
143
|
+
.map(
|
|
144
|
+
(v) =>
|
|
145
|
+
`EXISTS (SELECT 1 FROM ${tableName}_fts f WHERE f.rowid = ${tableAlias ?? tableName}.rowid AND ${tableName}_fts MATCH ${inlineValue(v)})`,
|
|
146
|
+
)
|
|
147
|
+
.join(' AND ')
|
|
148
|
+
fieldClauses += (anyField ? ' AND ' : '') + clause
|
|
149
|
+
anyField = true
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
|
|
146
153
|
if (opKey === 'is' || opKey === 'isNot' || opKey === 'in' || opKey === 'notIn') {
|
|
147
154
|
const fieldExpr = getFieldExpr(key, values[0], tableAlias)
|
|
148
155
|
const inList = values.map(inlineValue).join(',')
|
|
149
|
-
|
|
150
156
|
const clause =
|
|
151
157
|
opKey === 'is'
|
|
152
158
|
? values.length > 1
|
|
@@ -159,13 +165,11 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
|
|
|
159
165
|
: opKey === 'in'
|
|
160
166
|
? `${fieldExpr} IN (${inList})`
|
|
161
167
|
: `${fieldExpr} NOT IN (${inList})`
|
|
162
|
-
|
|
163
168
|
fieldClauses += (anyField ? ' AND ' : '') + clause
|
|
164
169
|
anyField = true
|
|
165
170
|
continue
|
|
166
171
|
}
|
|
167
172
|
|
|
168
|
-
// gt / gte / lt / lte / like
|
|
169
173
|
for (const v of values) {
|
|
170
174
|
const fieldExpr = getFieldExpr(key, v, tableAlias)
|
|
171
175
|
const clause =
|
|
@@ -190,10 +194,11 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
|
|
|
190
194
|
/**
|
|
191
195
|
* Get SQL WHERE clause from a Where object
|
|
192
196
|
* @param where The Where object defining the conditions
|
|
197
|
+
* @param tableName Optional table name (required for FTS conditions)
|
|
193
198
|
* @returns The SQL WHERE clause string (without the "WHERE" keyword)
|
|
194
199
|
*/
|
|
195
|
-
export function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T
|
|
200
|
+
export function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>, tableName?: string): string {
|
|
196
201
|
if (!where) return ''
|
|
197
|
-
const clause = getWhere(where)
|
|
202
|
+
const clause = getWhere(where, undefined, tableName)
|
|
198
203
|
return clause ? `WHERE ${clause}` : ''
|
|
199
204
|
}
|
|
@@ -2,20 +2,20 @@ import type { Table, DbOptions, DocType } from './table.types';
|
|
|
2
2
|
export declare const DEFAULT_STEP_SIZE = 100;
|
|
3
3
|
/**
|
|
4
4
|
* Convert a dot-separated path to a JSON path
|
|
5
|
-
* @param dot The dot-separated path
|
|
6
|
-
* @returns The JSON path
|
|
5
|
+
* @param dot The dot-separated path string
|
|
6
|
+
* @returns The JSON path string
|
|
7
7
|
*/
|
|
8
8
|
export declare function toJsonPath(dot: string): string;
|
|
9
9
|
/**
|
|
10
10
|
* Get a nested value from an object using a dot-separated path
|
|
11
|
-
* @param object The object to
|
|
12
|
-
* @param path The dot-separated path
|
|
11
|
+
* @param object The object to retrieve the value from
|
|
12
|
+
* @param path The dot-separated path string
|
|
13
13
|
* @returns The value at the specified path, or undefined if not found
|
|
14
14
|
*/
|
|
15
15
|
export declare function getByPath<T extends object>(object: T, path: string): unknown;
|
|
16
16
|
/**
|
|
17
|
-
* Create a
|
|
18
|
-
* @param options The options for creating the table
|
|
19
|
-
* @returns
|
|
17
|
+
* Create and initialize a table in the database with the specified options
|
|
18
|
+
* @param options The options for creating the table, including table name, indexes, backend, and key
|
|
19
|
+
* @returns A promise that resolves to the created Table instance
|
|
20
20
|
*/
|
|
21
21
|
export declare function createTable<Document extends DocType>(options: DbOptions<Document>): Promise<Table<Document>>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SqlSeachOptions } from '../select-sql';
|
|
2
2
|
import type { Backend } from './backend';
|
|
3
|
+
import type { FtsTokenizerOptions } from './tokenizer';
|
|
3
4
|
import type { Where } from './where';
|
|
4
5
|
export type DocType = {
|
|
5
6
|
[key: string]: any;
|
|
@@ -10,9 +11,16 @@ type Previous = [never, 0, 1, 2, 3, 4, 5];
|
|
|
10
11
|
export type DotPath<T, D extends number = 5> = [D] extends [never] ? never : T extends object ? {
|
|
11
12
|
[K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K;
|
|
12
13
|
}[Extract<keyof T, string>] : never;
|
|
14
|
+
export type FtsTokenizer = 'porter' | 'simple' | 'icu' | 'unicode61' | FtsTokenizerOptions;
|
|
15
|
+
export interface FtsType<Document extends DocType> {
|
|
16
|
+
readonly type: 'fts';
|
|
17
|
+
readonly path: DotPath<Document>;
|
|
18
|
+
readonly tokenizer?: FtsTokenizer;
|
|
19
|
+
}
|
|
20
|
+
export type IndexDeclaration<Document extends DocType> = DotPath<Document> | `fts:${DotPath<Document>}` | FtsType<Document>;
|
|
13
21
|
export interface DbOptions<Document extends DocType> {
|
|
14
22
|
readonly tableName: string;
|
|
15
|
-
readonly indexes?: Array<
|
|
23
|
+
readonly indexes?: Array<IndexDeclaration<Document>>;
|
|
16
24
|
readonly backend: Backend;
|
|
17
25
|
readonly key?: DotPath<Document>;
|
|
18
26
|
readonly disablePragmaOptimization?: boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface FtsTokenizerOptions {
|
|
2
|
+
readonly removeDiacritics?: 0 | 1 | 2;
|
|
3
|
+
readonly tokenChars?: string;
|
|
4
|
+
readonly separators?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Create a custom FTS5 tokenizer string based on the provided options
|
|
8
|
+
* @param options Options to customize the tokenizer
|
|
9
|
+
* @returns A string representing the FTS5 tokenizer configuration
|
|
10
|
+
*/
|
|
11
|
+
export declare function unicodeTokenizer(options?: FtsTokenizerOptions): string;
|
|
@@ -8,6 +8,7 @@ interface Condition<T> {
|
|
|
8
8
|
readonly in?: T[];
|
|
9
9
|
readonly notIn?: T[];
|
|
10
10
|
readonly like?: T | T[];
|
|
11
|
+
readonly fts?: string | string[];
|
|
11
12
|
}
|
|
12
13
|
export type Where<T extends Record<string, unknown>> = {
|
|
13
14
|
[K in keyof T]?: T[K] extends Record<string, unknown> ? Where<T[K]> : Condition<T[K]> | T[K] | T[K][];
|
|
@@ -20,13 +21,15 @@ export type Where<T extends Record<string, unknown>> = {
|
|
|
20
21
|
* Write SQL WHERE clause from a Where object
|
|
21
22
|
* @param where The Where object defining the conditions
|
|
22
23
|
* @param tableAlias Optional table alias to prefix field names
|
|
24
|
+
* @param tableName Optional table name (required for FTS conditions)
|
|
23
25
|
* @returns The SQL WHERE clause string (without the "WHERE" keyword)
|
|
24
26
|
*/
|
|
25
|
-
export declare function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string): string;
|
|
27
|
+
export declare function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string, tableName?: string): string;
|
|
26
28
|
/**
|
|
27
29
|
* Get SQL WHERE clause from a Where object
|
|
28
30
|
* @param where The Where object defining the conditions
|
|
31
|
+
* @param tableName Optional table name (required for FTS conditions)
|
|
29
32
|
* @returns The SQL WHERE clause string (without the "WHERE" keyword)
|
|
30
33
|
*/
|
|
31
|
-
export declare function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T
|
|
34
|
+
export declare function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>, tableName?: string): string;
|
|
32
35
|
export {};
|