muya 2.2.1 → 2.2.3
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/table/table.js +6 -5
- package/esm/sqlite/table/where.js +1 -1
- package/package.json +1 -1
- package/src/sqlite/__tests__/table.test.ts +39 -0
- package/src/sqlite/__tests__/use-sqlite.test.tsx +280 -0
- package/src/sqlite/table/table.ts +44 -29
- package/src/sqlite/table/table.types.ts +20 -15
- package/src/sqlite/table/where.ts +118 -203
- package/types/sqlite/table/table.d.ts +1 -0
- package/types/sqlite/table/table.types.d.ts +11 -14
- package/types/sqlite/table/where.d.ts +17 -28
|
@@ -1 +1 @@
|
|
|
1
|
-
import{bunMemoryBackend as
|
|
1
|
+
import{bunMemoryBackend as r}from"../table/bun-backend";import{createTable as l}from"../table/table";describe("table",()=>{let c=r(),e,s;beforeEach(async()=>{c=r(),e=await l({backend:c,tableName:"TestTable",key:"name"}),s=await l({backend:c,tableName:"TestTableNested",key:"info.name",indexes:["info.age","info.city"]})}),it("should set and get items",async()=>{const a=await e.set({name:"Alice",age:30,city:"Paris"});expect(a.key).toBe("Alice"),expect(a.op).toBe("insert");const o=await e.get("Alice");expect(o).toEqual({name:"Alice",age:30,city:"Paris"});const t=await e.set({name:"Alice",age:31,city:"Paris"});expect(t.key).toBe("Alice"),expect(t.op).toBe("update");const i=await e.get("Alice");expect(i).toEqual({name:"Alice",age:31,city:"Paris"})}),it("should set and get nested key",async()=>{const a=await s.set({info:{name:"Bob",age:25,city:"London"}});expect(a.key).toBe("Bob"),expect(a.op).toBe("insert");const o=await s.get("Bob");expect(o).toEqual({info:{name:"Bob",age:25,city:"London"}});const t=await s.set({info:{name:"Bob",age:26,city:"London"}});expect(t.key).toBe("Bob"),expect(t.op).toBe("update");const i=await s.get("Bob");expect(i).toEqual({info:{name:"Bob",age:26,city:"London"}});const n=[];for await(const d of s.search({where:{info:{city:{like:"London"}}}}))n.push(d);expect(n.length).toBe(1)}),it("should count items and count with where",async()=>{await e.set({name:"Alice",age:30,city:"Paris"}),await e.set({name:"Bob",age:25,city:"London"}),expect(await e.count()).toBe(2),expect(await e.count({where:{city:"Paris"}})).toBe(1)}),it("should search with ordering, limit and offset",async()=>{const a=[{name:"Alice",age:30,city:"Paris"},{name:"Bob",age:25,city:"London"},{name:"Carol",age:35,city:"Berlin"}];for(const n of a)await e.set(n);const o=[];for await(const n of e.search({sortBy:"age",order:"asc"}))o.push(n);expect(o.map(n=>n.name)).toEqual(["Bob","Alice","Carol"]);const t=[];for await(const n of e.search({sortBy:"age",order:"asc",limit:2}))t.push(n);expect(t.map(n=>n.name)).toEqual(["Bob","Alice"]);const i=[];for await(const n of e.search({sortBy:"age",order:"asc",offset:1,limit:2}))i.push(n);expect(i.map(n=>n.name)).toEqual(["Alice","Carol"])}),it("should deleteBy where clause",async()=>{await e.set({name:"Dave",age:40,city:"NY"}),await e.set({name:"Eve",age:45,city:"NY"}),await e.set({name:"Frank",age:50,city:"LA"}),expect(await e.count()).toBe(3),await e.deleteBy({city:"NY"}),expect(await e.count()).toBe(1),expect(await e.get("Frank")).toEqual({name:"Frank",age:50,city:"LA"}),expect(await e.get("Dave")).toBeUndefined()}),it("should use selector in get and search",async()=>{await e.set({name:"Gary",age:60,city:"SF"});const a=await e.get("Gary",({age:t})=>t);expect(a).toBe(60);const o=[];for await(const t of e.search({select:({city:i})=>i}))o.push(t);expect(o).toEqual(["SF"])}),it("should delete items by key",async()=>{await e.set({name:"Helen",age:28,city:"Rome"}),expect(await e.get("Helen")).toBeDefined(),await e.delete("Helen"),expect(await e.get("Helen")).toBeUndefined()}),it("should test search with 1000 items",async()=>{const a=[];for(let t=0;t<1e3;t++)a.push({name:`Person${t}`,age:Math.floor(Math.random()*100),city:"City"+t%10});for(const t of a)await e.set(t);const o=[];for await(const t of e.search({sortBy:"age",order:"asc",limit:100}))o.push(t);expect(o.length).toBe(100)}),it("should handle operations on an empty table",async()=>{expect(await e.count()).toBe(0),expect(await e.get("NonExistent")).toBeUndefined();const a=[];for await(const o of e.search({sortBy:"age",order:"asc"}))a.push(o);expect(a.length).toBe(0)}),it("should handle duplicate keys gracefully",async()=>{await e.set({name:"Alice",age:30,city:"Paris"}),await e.set({name:"Alice",age:35,city:"Berlin"});const a=await e.get("Alice");expect(a).toEqual({name:"Alice",age:35,city:"Berlin"})}),it("should handle edge cases in selectors",async()=>{await e.set({name:"Charlie",age:40,city:"NY"});const a=await e.get("Charlie",()=>null);expect(a).toBeNull();const o=await e.get("Charlie",()=>{});expect(o).toBeUndefined()})});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import{getWhereQuery as
|
|
2
|
-
CREATE TABLE IF NOT EXISTS ${
|
|
1
|
+
import{getWhereQuery as h}from"./where";const O=500,N=100;function D(l){return"$."+l}function I(l,r){if(!(!l||!r))return r.split(".").reduce((o,y)=>{if(typeof o=="object"&&o!==null&&y in o)return o[y]},l)}async function M(l){const{backend:r,tableName:o,indexes:y,key:S,disablePragmaOptimization:k}=l,d=S!==void 0;k||(await r.execute("PRAGMA journal_mode=WAL;"),await r.execute("PRAGMA synchronous=NORMAL;"),await r.execute("PRAGMA temp_store=MEMORY;"),await r.execute("PRAGMA cache_size=-20000;")),d?await r.execute(`
|
|
2
|
+
CREATE TABLE IF NOT EXISTS ${o} (
|
|
3
3
|
key TEXT PRIMARY KEY,
|
|
4
4
|
data TEXT NOT NULL
|
|
5
5
|
);
|
|
6
|
-
`):await
|
|
7
|
-
CREATE TABLE IF NOT EXISTS ${
|
|
6
|
+
`):await r.execute(`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS ${o} (
|
|
8
8
|
data TEXT NOT NULL
|
|
9
9
|
);
|
|
10
|
-
`);for(const
|
|
10
|
+
`);for(const t of y??[]){const a=String(t);await r.execute(`CREATE INDEX IF NOT EXISTS idx_${o}_${a.replaceAll(/\W/g,"_")}
|
|
11
|
+
ON ${o} (json_extract(data, '${D(a)}'));`)}function $(t){if(d)return I(t,String(S))}async function g(t){return(await t.select("SELECT changes() AS c"))[0]?.c??0}const p={backend:r,async set(t,a){const e=a??r,n=JSON.stringify(t);if(d){const s=$(t);if(s==null)throw new Error(`Document is missing the configured key "${String(S)}". Provide it or create the table without "key".`);if(await e.execute(`UPDATE ${o} SET data = ? WHERE key = ?`,[n,s]),await g(e)===1)return{key:s,op:"update"};try{return await e.execute(`INSERT INTO ${o} (key, data) VALUES (?, ?)`,[s,n]),{key:s,op:"insert"}}catch{return await e.execute(`UPDATE ${o} SET data = ? WHERE key = ?`,[n,s]),{key:s,op:"update"}}}await e.execute(`INSERT INTO ${o} (data) VALUES (?)`,[n]);const u=(await e.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(t,a=e=>e){const e=d?"key = ?":"rowid = ?",n=await r.select(`SELECT rowid, data FROM ${o} WHERE ${e}`,[t]);if(n.length===0)return;const{data:i,rowid:u}=n[0],s=JSON.parse(i);return a(s,{rowId:u})},async delete(t){const a=d?"key = ?":"rowid = ?";if(await r.execute(`DELETE FROM ${o} WHERE ${a}`,[t]),((await r.select("SELECT changes() AS c"))[0]?.c??0)>0)return{key:t,op:"delete"}},async*search(t={}){const{sortBy:a,order:e="asc",limit:n,offset:i=0,where:u,select:s=E=>E,stepSize:c=N}=t,w=h(u),f=`SELECT rowid, data FROM ${o} ${w}`;let T=0,A=i;for(;;){let E=f;a?E+=` ORDER BY json_extract(data, '${D(String(a))}') COLLATE NOCASE ${e.toUpperCase()}`:E+=d?` ORDER BY key COLLATE NOCASE ${e.toUpperCase()}`:` ORDER BY rowid ${e.toUpperCase()}`;const R=n?Math.min(c,n-T):c;E+=` LIMIT ${R} OFFSET ${A}`;const m=await r.select(E);if(m.length===0)break;for(const{rowid:b,data:L}of m){if(n&&T>=n)return;const x=JSON.parse(L);yield s(x,{rowId:b}),T++}if(m.length<R||n&&T>=n)break;A+=m.length}},async count(t={}){const a=h(t.where),e=`SELECT COUNT(*) as count FROM ${o} ${a}`;return(await r.select(e))[0]?.count??0},async deleteBy(t){const a=h(t),e=d?"key":"rowid",n=[];return await r.transaction(async i=>{const u=await i.select(`SELECT ${e} AS k FROM ${o} ${a}`);if(u.length===0)return;const s=u.map(c=>c.k);for(let c=0;c<s.length;c+=O){const w=s.slice(c,c+O),f=w.map(()=>"?").join(",");await i.execute(`DELETE FROM ${o} WHERE ${e} IN (${f})`,w)}for(const c of s)n.push({key:c,op:"delete"})}),n},async batchSet(t){const a=[];return await r.transaction(async e=>{for(const n of t){const i=await p.set(n,e);a.push(i)}}),a}};return p}export{N as DEFAULT_STEP_SIZE,M as createTable,I as getByPath};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
function
|
|
1
|
+
function l(n){return typeof n=="string"?`'${n.replaceAll("'","''")}'`:typeof n=="number"?n.toString():typeof n=="boolean"?n?"1":"0":`'${String(n).replaceAll("'","''")}'`}function g(n,e,u){const t=u?`${u}.`:"";return n==="KEY"?`"${t}key"`:typeof e=="string"?`CAST(json_extract(${t}data, '$.${n}') AS TEXT)`:typeof e=="number"?`CAST(json_extract(${t}data, '$.${n}') AS NUMERIC)`:typeof e=="boolean"?`CAST(json_extract(${t}data, '$.${n}') AS INTEGER)`:`json_extract(${t}data, '$.${n}')`}const A=new Set(["is","isNot","gt","gte","lt","lte","in","notIn","like"]);function N(n,e=""){const u={};for(const[t,o]of Object.entries(n)){if(t==="AND"||t==="OR"||t==="NOT"){u[t]=o;continue}const r=e?`${e}.${t}`:t;o&&typeof o=="object"&&!Array.isArray(o)&&!Object.keys(o).some(i=>A.has(i))?Object.assign(u,N(o,r)):u[r]=o}return u}function T(n,e){if(!n||typeof n!="object")return"";if(n.AND){const r=Array.isArray(n.AND)?n.AND.map(i=>T(i,e)).filter(Boolean):[];return r.length>0?`(${r.join(" AND ")})`:""}if(n.OR){const r=Array.isArray(n.OR)?n.OR.map(i=>T(i,e)).filter(Boolean):[];return r.length>0?`(${r.join(" OR ")})`:""}if(n.NOT){const r=T(n.NOT,e);return r?`(NOT ${r})`:""}const u=N(n);let t="",o=!1;for(const[r,i]of Object.entries(u)){if(i==null)continue;let y;typeof i!="object"||Array.isArray(i)?y=Array.isArray(i)?{in:i}:{is:i}:y=i;for(const s of Object.keys(y)){if(!A.has(s))continue;const $=y[s];if($==null)continue;const f=Array.isArray($)?$:[$];if(f.length!==0){if(s==="is"||s==="isNot"||s==="in"||s==="notIn"){const c=g(r,f[0],e),a=f.map(l).join(","),d=s==="is"?f.length>1?`${c} IN (${a})`:`${c} = ${l(f[0])}`:s==="isNot"?f.length>1?`${c} NOT IN (${a})`:`${c} <> ${l(f[0])}`:s==="in"?`${c} IN (${a})`:`${c} NOT IN (${a})`;t+=(o?" AND ":"")+d,o=!0;continue}for(const c of f){const a=g(r,c,e),d=s==="gt"?`${a} > ${l(c)}`:s==="gte"?`${a} >= ${l(c)}`:s==="lt"?`${a} < ${l(c)}`:s==="lte"?`${a} <= ${l(c)}`:`${a} LIKE ${l(c)}`;t+=(o?" AND ":"")+d,o=!0}}}}return o?`(${t})`:""}function p(n){if(!n)return"";const e=T(n);return e?`WHERE ${e}`:""}export{T as getWhere,p as getWhereQuery};
|
package/package.json
CHANGED
|
@@ -12,10 +12,20 @@ interface Person {
|
|
|
12
12
|
age: number
|
|
13
13
|
city: string
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
interface PersonNested {
|
|
17
|
+
info: {
|
|
18
|
+
name: string
|
|
19
|
+
age: number
|
|
20
|
+
city: string
|
|
21
|
+
}
|
|
22
|
+
}
|
|
15
23
|
describe('table', () => {
|
|
16
24
|
let backend = bunMemoryBackend()
|
|
17
25
|
let table: ReturnType<typeof createTable<Person>> extends Promise<infer T> ? T : never
|
|
18
26
|
|
|
27
|
+
let tableNested: ReturnType<typeof createTable<PersonNested>> extends Promise<infer T> ? T : never
|
|
28
|
+
|
|
19
29
|
beforeEach(async () => {
|
|
20
30
|
backend = bunMemoryBackend()
|
|
21
31
|
table = await createTable<Person>({
|
|
@@ -23,6 +33,12 @@ describe('table', () => {
|
|
|
23
33
|
tableName: 'TestTable',
|
|
24
34
|
key: 'name',
|
|
25
35
|
})
|
|
36
|
+
tableNested = await createTable<PersonNested>({
|
|
37
|
+
backend,
|
|
38
|
+
tableName: 'TestTableNested',
|
|
39
|
+
key: 'info.name',
|
|
40
|
+
indexes: ['info.age', 'info.city'],
|
|
41
|
+
})
|
|
26
42
|
})
|
|
27
43
|
|
|
28
44
|
it('should set and get items', async () => {
|
|
@@ -39,6 +55,29 @@ describe('table', () => {
|
|
|
39
55
|
expect(updatedResult).toEqual({ name: 'Alice', age: 31, city: 'Paris' })
|
|
40
56
|
})
|
|
41
57
|
|
|
58
|
+
it('should set and get nested key', async () => {
|
|
59
|
+
const mutation = await tableNested.set({ info: { name: 'Bob', age: 25, city: 'London' } })
|
|
60
|
+
expect(mutation.key).toBe('Bob')
|
|
61
|
+
expect(mutation.op).toBe('insert')
|
|
62
|
+
const result = await tableNested.get('Bob')
|
|
63
|
+
expect(result).toEqual({ info: { name: 'Bob', age: 25, city: 'London' } })
|
|
64
|
+
|
|
65
|
+
const updateMutation = await tableNested.set({ info: { name: 'Bob', age: 26, city: 'London' } })
|
|
66
|
+
expect(updateMutation.key).toBe('Bob')
|
|
67
|
+
expect(updateMutation.op).toBe('update')
|
|
68
|
+
const updatedResult = await tableNested.get('Bob')
|
|
69
|
+
expect(updatedResult).toEqual({ info: { name: 'Bob', age: 26, city: 'London' } })
|
|
70
|
+
|
|
71
|
+
// nested where query
|
|
72
|
+
const results: PersonNested[] = []
|
|
73
|
+
for await (const person of tableNested.search({
|
|
74
|
+
where: { info: { city: { like: 'London' } } },
|
|
75
|
+
})) {
|
|
76
|
+
results.push(person)
|
|
77
|
+
}
|
|
78
|
+
expect(results.length).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
42
81
|
it('should count items and count with where', async () => {
|
|
43
82
|
await table.set({ name: 'Alice', age: 30, city: 'Paris' })
|
|
44
83
|
await table.set({ name: 'Bob', age: 25, city: 'London' })
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react-hooks'
|
|
2
|
+
import { createSqliteState } from '../create-sqlite'
|
|
3
|
+
import { useSqliteValue } from '../use-sqlite'
|
|
4
|
+
import { waitFor } from '@testing-library/react'
|
|
5
|
+
import { bunMemoryBackend } from '../table/bun-backend'
|
|
6
|
+
import { Suspense, useState } from 'react'
|
|
7
|
+
import { DEFAULT_STEP_SIZE } from '../table/table'
|
|
8
|
+
|
|
9
|
+
const backend = bunMemoryBackend()
|
|
10
|
+
interface Person {
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
age: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function Wrapper({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
17
|
+
return <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
|
18
|
+
}
|
|
19
|
+
describe('use-sqlite-state', () => {
|
|
20
|
+
it('should get basic value states', async () => {
|
|
21
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State1', key: 'id' })
|
|
22
|
+
let reRenders = 0
|
|
23
|
+
const { result } = renderHook(
|
|
24
|
+
() => {
|
|
25
|
+
reRenders++
|
|
26
|
+
const aha = useSqliteValue(sql, {}, [])
|
|
27
|
+
return aha
|
|
28
|
+
},
|
|
29
|
+
{ wrapper: Wrapper },
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(reRenders).toBe(1)
|
|
33
|
+
|
|
34
|
+
act(() => {
|
|
35
|
+
sql.set({ id: '1', name: 'Alice', age: 30 })
|
|
36
|
+
})
|
|
37
|
+
await waitFor(() => {
|
|
38
|
+
expect(result.current[0]).toEqual([{ id: '1', name: 'Alice', age: 30 }])
|
|
39
|
+
expect(reRenders).toBe(3)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// act(() => {
|
|
43
|
+
// sql.set({ id: '1', name: 'Alice2', age: 30 })
|
|
44
|
+
// })
|
|
45
|
+
// await waitFor(() => {
|
|
46
|
+
// expect(result.current[0]).toEqual([{ id: '1', name: 'Alice2', age: 30 }])
|
|
47
|
+
// expect(reRenders).toBe(4)
|
|
48
|
+
// })
|
|
49
|
+
|
|
50
|
+
// // delete item
|
|
51
|
+
// act(() => {
|
|
52
|
+
// sql.delete('1')
|
|
53
|
+
// })
|
|
54
|
+
// await waitFor(() => {
|
|
55
|
+
// expect(result.current[0]).toEqual([])
|
|
56
|
+
// expect(reRenders).toBe(5)
|
|
57
|
+
// })
|
|
58
|
+
|
|
59
|
+
// // add two items
|
|
60
|
+
// act(() => {
|
|
61
|
+
// sql.set({ id: '1', name: 'Alice', age: 30 })
|
|
62
|
+
// sql.set({ id: '2', name: 'Bob', age: 25 })
|
|
63
|
+
// })
|
|
64
|
+
// await waitFor(() => {
|
|
65
|
+
// expect(result.current[0].length).toBe(2)
|
|
66
|
+
// expect(reRenders).toBe(6)
|
|
67
|
+
// })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should use where clause changed via state', async () => {
|
|
71
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State2', key: 'id' })
|
|
72
|
+
await sql.batchSet([
|
|
73
|
+
{ id: '1', name: 'Alice', age: 30 },
|
|
74
|
+
{ id: '2', name: 'Bob', age: 25 },
|
|
75
|
+
{ id: '3', name: 'Carol', age: 40 },
|
|
76
|
+
])
|
|
77
|
+
let reRenders = 0
|
|
78
|
+
const { result } = renderHook(() => {
|
|
79
|
+
reRenders++
|
|
80
|
+
const [minAge, setMinAge] = useState(20)
|
|
81
|
+
return [useSqliteValue(sql, { where: { age: { gt: minAge } }, sorBy: 'age' }, [minAge]), setMinAge] as const
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(result.current[0][0].map((p) => p.name)).toEqual(['Alice', 'Bob', 'Carol'])
|
|
86
|
+
expect(reRenders).toBe(2)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// // change minAge to 29
|
|
90
|
+
act(() => {
|
|
91
|
+
result.current[1](29)
|
|
92
|
+
})
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(result.current[0][0].map((p) => p.name)).toEqual(['Alice', 'Carol'])
|
|
95
|
+
expect(reRenders).toBe(4)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should support like in where clause and update results', async () => {
|
|
100
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State3', key: 'id' })
|
|
101
|
+
await sql.batchSet([
|
|
102
|
+
{ id: '1', name: 'Alice', age: 30 },
|
|
103
|
+
{ id: '2', name: 'Alicia', age: 25 },
|
|
104
|
+
{ id: '3', name: 'Bob', age: 40 },
|
|
105
|
+
])
|
|
106
|
+
let reRenders = 0
|
|
107
|
+
const { result, rerender } = renderHook(
|
|
108
|
+
({ like }) => {
|
|
109
|
+
reRenders++
|
|
110
|
+
return useSqliteValue(sql, { where: { name: { like } } }, [like])
|
|
111
|
+
},
|
|
112
|
+
{ initialProps: { like: '%Ali%' } },
|
|
113
|
+
)
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(result.current[0].map((p) => p.name)).toEqual(['Alice', 'Alicia'])
|
|
116
|
+
})
|
|
117
|
+
act(() => {
|
|
118
|
+
rerender({ like: '%Bob%' })
|
|
119
|
+
})
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(result.current[0].map((p) => p.name)).toEqual(['Bob'])
|
|
122
|
+
})
|
|
123
|
+
expect(reRenders).toBeGreaterThanOrEqual(2)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should update results when changing order and limit options', async () => {
|
|
127
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State4', key: 'id' })
|
|
128
|
+
await sql.batchSet([
|
|
129
|
+
{ id: '1', name: 'Alice', age: 30 },
|
|
130
|
+
{ id: '2', name: 'Bob', age: 25 },
|
|
131
|
+
{ id: '3', name: 'Carol', age: 40 },
|
|
132
|
+
])
|
|
133
|
+
const { result, rerender } = renderHook(
|
|
134
|
+
({ order, limit }) => useSqliteValue(sql, { sorBy: 'age', order, limit }, [order, limit]),
|
|
135
|
+
{ initialProps: { order: 'asc' as 'asc' | 'desc', limit: 2 } },
|
|
136
|
+
)
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(result.current[0].map((p) => p.name)).toEqual(['Alice', 'Bob'])
|
|
139
|
+
})
|
|
140
|
+
act(() => {
|
|
141
|
+
rerender({ order: 'desc', limit: 2 })
|
|
142
|
+
})
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(result.current[0].map((p) => p.name)).toEqual(['Alice', 'Bob'])
|
|
145
|
+
})
|
|
146
|
+
act(() => {
|
|
147
|
+
rerender({ order: 'desc', limit: 1 })
|
|
148
|
+
})
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
expect(result.current[0].map((p) => p.name)).toEqual(['Carol'])
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should support actions.next and actions.refresh', async () => {
|
|
155
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State5', key: 'id' })
|
|
156
|
+
await sql.batchSet([
|
|
157
|
+
{ id: '1', name: 'Alice', age: 30 },
|
|
158
|
+
{ id: '2', name: 'Bob', age: 25 },
|
|
159
|
+
])
|
|
160
|
+
const { result } = renderHook(() => useSqliteValue(sql, {}, []))
|
|
161
|
+
// actions.next and actions.refresh should be functions
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(typeof result.current[1].next).toBe('function')
|
|
164
|
+
expect(typeof result.current[1].reset).toBe('function')
|
|
165
|
+
expect(result.current[1].reset()).resolves.toBeUndefined()
|
|
166
|
+
expect(result.current[1].next()).resolves.toBeFalsy()
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
it('should handle thousands of records', async () => {
|
|
170
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State6', key: 'id' })
|
|
171
|
+
const people: Person[] = []
|
|
172
|
+
const ITEMS_COUNT = 1000
|
|
173
|
+
for (let index = 1; index <= ITEMS_COUNT; index++) {
|
|
174
|
+
people.push({ id: index.toString(), name: `Person${index}`, age: 20 + (index % 50) })
|
|
175
|
+
}
|
|
176
|
+
await sql.batchSet(people)
|
|
177
|
+
const { result } = renderHook(() => useSqliteValue(sql, {}, []))
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(result.current[0].length).toBe(DEFAULT_STEP_SIZE)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// loop until we have all ITEMS_COUNT items
|
|
183
|
+
for (let index = 0; index < ITEMS_COUNT / DEFAULT_STEP_SIZE; index++) {
|
|
184
|
+
act(() => {
|
|
185
|
+
result.current[1].next()
|
|
186
|
+
})
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(result.current[0].length).toBe(Math.min(DEFAULT_STEP_SIZE * (index + 2), ITEMS_COUNT))
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
act(() => {
|
|
193
|
+
result.current[1].reset()
|
|
194
|
+
})
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(result.current[0].length).toBe(DEFAULT_STEP_SIZE)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should handle thousands of records with single update', async () => {
|
|
201
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State6', key: 'id' })
|
|
202
|
+
const people: Person[] = []
|
|
203
|
+
const ITEMS_COUNT = 10_000
|
|
204
|
+
const stepSize = 5000
|
|
205
|
+
for (let index = 1; index <= ITEMS_COUNT; index++) {
|
|
206
|
+
people.push({ id: index.toString(), name: `Person${index}`, age: 20 + (index % 50) })
|
|
207
|
+
}
|
|
208
|
+
await sql.batchSet(people)
|
|
209
|
+
let reRenders = 0
|
|
210
|
+
const { result } = renderHook(() => {
|
|
211
|
+
reRenders++
|
|
212
|
+
return useSqliteValue(sql, { stepSize }, [])
|
|
213
|
+
})
|
|
214
|
+
await waitFor(() => {
|
|
215
|
+
expect(reRenders).toBe(2)
|
|
216
|
+
expect(result.current[0].length).toBe(stepSize)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
act(() => {
|
|
220
|
+
for (let index = 0; index < ITEMS_COUNT / stepSize; index++) {
|
|
221
|
+
result.current[1].next()
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await waitFor(() => {
|
|
226
|
+
expect(reRenders).toBe(4)
|
|
227
|
+
expect(result.current[0].length).toBe(ITEMS_COUNT)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
act(() => {
|
|
231
|
+
result.current[1].reset()
|
|
232
|
+
})
|
|
233
|
+
await waitFor(() => {
|
|
234
|
+
expect(reRenders).toBe(5)
|
|
235
|
+
expect(result.current[0].length).toBe(stepSize)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
it('should change ordering', async () => {
|
|
239
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State7', key: 'id', indexes: ['age'] })
|
|
240
|
+
const people: Person[] = []
|
|
241
|
+
for (let index = 1; index <= 100; index++) {
|
|
242
|
+
people.push({ id: index.toString(), name: `Person${index}`, age: 20 + (index % 50) })
|
|
243
|
+
}
|
|
244
|
+
await sql.batchSet(people)
|
|
245
|
+
const { result, rerender } = renderHook(({ order }) => useSqliteValue(sql, { sorBy: 'age', order }, [order]), {
|
|
246
|
+
initialProps: { order: 'asc' as 'asc' | 'desc' },
|
|
247
|
+
})
|
|
248
|
+
await waitFor(() => {
|
|
249
|
+
expect(result.current[0][0].age).toBe(21)
|
|
250
|
+
})
|
|
251
|
+
act(() => {
|
|
252
|
+
rerender({ order: 'desc' })
|
|
253
|
+
})
|
|
254
|
+
await waitFor(() => {
|
|
255
|
+
expect(result.current[0][0].age).toBe(69)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('should support selector in options', async () => {
|
|
260
|
+
const sql = createSqliteState<Person>({ backend, tableName: 'State8', key: 'id' })
|
|
261
|
+
await sql.batchSet([
|
|
262
|
+
{ id: '1', name: 'Alice', age: 30 },
|
|
263
|
+
{ id: '2', name: 'Bob', age: 25 },
|
|
264
|
+
{ id: '3', name: 'Carol', age: 40 },
|
|
265
|
+
])
|
|
266
|
+
const { result } = renderHook(() =>
|
|
267
|
+
useSqliteValue(
|
|
268
|
+
sql,
|
|
269
|
+
{
|
|
270
|
+
sorBy: 'age',
|
|
271
|
+
select: (d) => d.name,
|
|
272
|
+
},
|
|
273
|
+
[],
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
expect(result.current[0]).toEqual(['Alice', 'Bob', 'Carol'])
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
})
|
|
@@ -1,19 +1,37 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable prefer-destructuring */
|
|
2
2
|
/* eslint-disable sonarjs/different-types-comparison */
|
|
3
3
|
/* eslint-disable sonarjs/cognitive-complexity */
|
|
4
4
|
/* eslint-disable @typescript-eslint/no-shadow */
|
|
5
5
|
/* eslint-disable no-shadow */
|
|
6
|
+
|
|
6
7
|
import type { Table, DbOptions, DocType, Key, SearchOptions, MutationResult } from './table.types'
|
|
7
|
-
import {
|
|
8
|
+
import type { Where } from './where'
|
|
9
|
+
import { getWhereQuery } from './where'
|
|
8
10
|
|
|
9
11
|
const DELETE_IN_CHUNK = 500 // keep well below SQLite's default 999 parameter limit
|
|
10
12
|
export const DEFAULT_STEP_SIZE = 100
|
|
13
|
+
|
|
14
|
+
// --- Helpers for JSON dot paths ---
|
|
15
|
+
function toJsonPath(dot: string) {
|
|
16
|
+
return '$.' + dot
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getByPath<T extends object>(object: T, path: string): unknown {
|
|
20
|
+
if (!object || !path) return undefined
|
|
21
|
+
// eslint-disable-next-line unicorn/no-array-reduce
|
|
22
|
+
return path.split('.').reduce<unknown>((accumulator, key) => {
|
|
23
|
+
if (typeof accumulator === 'object' && accumulator !== null && key in (accumulator as Record<string, unknown>)) {
|
|
24
|
+
return (accumulator as Record<string, unknown>)[key]
|
|
25
|
+
}
|
|
26
|
+
return
|
|
27
|
+
}, object)
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
export async function createTable<Document extends DocType>(options: DbOptions<Document>): Promise<Table<Document>> {
|
|
12
31
|
const { backend, tableName, indexes, key, disablePragmaOptimization } = options
|
|
13
32
|
const hasUserKey = key !== undefined
|
|
14
33
|
|
|
15
34
|
// --- Apply performance PRAGMAs unless explicitly disabled ---
|
|
16
|
-
// These significantly speed up write-heavy workloads on SQLite.
|
|
17
35
|
if (!disablePragmaOptimization) {
|
|
18
36
|
await backend.execute(`PRAGMA journal_mode=WAL;`)
|
|
19
37
|
await backend.execute(`PRAGMA synchronous=NORMAL;`)
|
|
@@ -37,14 +55,18 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
37
55
|
`)
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
// JSON expression indexes
|
|
58
|
+
// JSON expression indexes
|
|
41
59
|
for (const index of indexes ?? []) {
|
|
42
60
|
const idx = String(index)
|
|
43
|
-
await backend.execute(
|
|
61
|
+
await backend.execute(
|
|
62
|
+
`CREATE INDEX IF NOT EXISTS idx_${tableName}_${idx.replaceAll(/\W/g, '_')}
|
|
63
|
+
ON ${tableName} (json_extract(data, '${toJsonPath(idx)}'));`,
|
|
64
|
+
)
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
function getKeyFromDocument(document: Document): Key | undefined {
|
|
47
|
-
|
|
68
|
+
if (!hasUserKey) return undefined
|
|
69
|
+
return getByPath(document, String(key)) as Key | undefined
|
|
48
70
|
}
|
|
49
71
|
|
|
50
72
|
async function getChanges(conn: typeof backend): Promise<number> {
|
|
@@ -87,14 +109,13 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
87
109
|
const rows = await db.select<Array<{ id: number }>>(`SELECT last_insert_rowid() AS id`)
|
|
88
110
|
const rowid = rows[0]?.id
|
|
89
111
|
if (typeof rowid !== 'number') throw new Error('Failed to retrieve last_insert_rowid()')
|
|
90
|
-
|
|
91
|
-
return result
|
|
112
|
+
return { key: rowid, op: 'insert' }
|
|
92
113
|
},
|
|
93
114
|
|
|
94
|
-
// --- FIXED: include rowid ---
|
|
95
115
|
async get<Selected = Document>(
|
|
96
116
|
keyValue: Key,
|
|
97
|
-
|
|
117
|
+
// keep meta available and consistent with search() { rowId }
|
|
118
|
+
selector: (document: Document, meta: { rowId: number }) => Selected = (d) => d as unknown as Selected,
|
|
98
119
|
) {
|
|
99
120
|
const whereKey = hasUserKey ? `key = ?` : `rowid = ?`
|
|
100
121
|
const result = await backend.select<Array<{ data: string; rowid: number }>>(
|
|
@@ -102,23 +123,19 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
102
123
|
[keyValue],
|
|
103
124
|
)
|
|
104
125
|
if (result.length === 0) return
|
|
105
|
-
const
|
|
106
|
-
const { data, rowid } = item
|
|
126
|
+
const { data, rowid } = result[0]
|
|
107
127
|
const document = JSON.parse(data) as Document
|
|
108
|
-
return selector(document, { rowid }) as Selected
|
|
128
|
+
return selector(document, { rowId: rowid }) as Selected
|
|
109
129
|
},
|
|
110
130
|
|
|
111
131
|
async delete(keyValue: Key) {
|
|
112
132
|
const whereKey = hasUserKey ? `key = ?` : `rowid = ?`
|
|
113
133
|
await backend.execute(`DELETE FROM ${tableName} WHERE ${whereKey}`, [keyValue])
|
|
114
134
|
const changed = await backend.select<Array<{ c: number }>>(`SELECT changes() AS c`)
|
|
115
|
-
if ((changed[0]?.c ?? 0) > 0) {
|
|
116
|
-
return { key: keyValue, op: 'delete' }
|
|
117
|
-
}
|
|
135
|
+
if ((changed[0]?.c ?? 0) > 0) return { key: keyValue, op: 'delete' }
|
|
118
136
|
return
|
|
119
137
|
},
|
|
120
138
|
|
|
121
|
-
// --- FIXED: include rowid in search ---
|
|
122
139
|
async *search<Selected = Document>(options: SearchOptions<Document, Selected> = {}): AsyncIterableIterator<Selected> {
|
|
123
140
|
const {
|
|
124
141
|
sortBy,
|
|
@@ -126,12 +143,12 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
126
143
|
limit,
|
|
127
144
|
offset = 0,
|
|
128
145
|
where,
|
|
129
|
-
select = (document
|
|
146
|
+
select = (document) => document as unknown as Selected,
|
|
130
147
|
stepSize = DEFAULT_STEP_SIZE,
|
|
131
148
|
} = options
|
|
132
149
|
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
const whereSql = getWhereQuery<Document>(where)
|
|
151
|
+
const baseQuery = `SELECT rowid, data FROM ${tableName} ${whereSql}`
|
|
135
152
|
|
|
136
153
|
let yielded = 0
|
|
137
154
|
let currentOffset = offset
|
|
@@ -139,7 +156,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
139
156
|
let query = baseQuery
|
|
140
157
|
|
|
141
158
|
if (sortBy) {
|
|
142
|
-
query += ` ORDER BY json_extract(data, '
|
|
159
|
+
query += ` ORDER BY json_extract(data, '${toJsonPath(String(sortBy))}') COLLATE NOCASE ${order.toUpperCase()}`
|
|
143
160
|
} else {
|
|
144
161
|
query += hasUserKey ? ` ORDER BY key COLLATE NOCASE ${order.toUpperCase()}` : ` ORDER BY rowid ${order.toUpperCase()}`
|
|
145
162
|
}
|
|
@@ -163,28 +180,26 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
|
|
|
163
180
|
},
|
|
164
181
|
|
|
165
182
|
async count(options: { where?: Where<Document> } = {}) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
if (where) query += ' ' + getWhereQuery(where)
|
|
183
|
+
const whereSql = getWhereQuery<Document>(options.where)
|
|
184
|
+
const query = `SELECT COUNT(*) as count FROM ${tableName} ${whereSql}`
|
|
169
185
|
const result = await backend.select<Array<{ count: number }>>(query)
|
|
170
186
|
return result[0]?.count ?? 0
|
|
171
187
|
},
|
|
172
188
|
|
|
173
189
|
async deleteBy(where: Where<Document>) {
|
|
174
|
-
const
|
|
190
|
+
const whereSql = getWhereQuery<Document>(where)
|
|
175
191
|
const keyCol = hasUserKey ? 'key' : 'rowid'
|
|
176
|
-
|
|
177
192
|
const results: MutationResult[] = []
|
|
193
|
+
|
|
178
194
|
await backend.transaction(async (tx) => {
|
|
179
|
-
const rows = await tx.select<Array<{ k: Key }>>(`SELECT ${keyCol} AS k
|
|
195
|
+
const rows = await tx.select<Array<{ k: Key }>>(`SELECT ${keyCol} AS k FROM ${tableName} ${whereSql}`)
|
|
180
196
|
if (rows.length === 0) return
|
|
181
197
|
|
|
182
198
|
const allKeys = rows.map((r) => r.k)
|
|
183
|
-
|
|
184
199
|
for (let index = 0; index < allKeys.length; index += DELETE_IN_CHUNK) {
|
|
185
200
|
const chunk = allKeys.slice(index, index + DELETE_IN_CHUNK)
|
|
186
201
|
const placeholders = chunk.map(() => '?').join(',')
|
|
187
|
-
await tx.execute(`DELETE FROM ${tableName} WHERE ${keyCol} IN (${placeholders})`, chunk as unknown
|
|
202
|
+
await tx.execute(`DELETE FROM ${tableName} WHERE ${keyCol} IN (${placeholders})`, chunk as unknown[])
|
|
188
203
|
}
|
|
189
204
|
|
|
190
205
|
for (const k of allKeys) results.push({ key: k, op: 'delete' })
|
|
@@ -6,36 +6,41 @@ import type { Where } from './where'
|
|
|
6
6
|
export type DocType = { [key: string]: any }
|
|
7
7
|
export type KeyTypeAvailable = 'string' | 'number'
|
|
8
8
|
|
|
9
|
+
// Expand all nested keys into dot-paths
|
|
10
|
+
export type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`
|
|
11
|
+
|
|
12
|
+
type Previous = [never, 0, 1, 2, 3, 4, 5]
|
|
13
|
+
|
|
14
|
+
export type DotPath<T, D extends number = 5> = [D] extends [never]
|
|
15
|
+
? never
|
|
16
|
+
: T extends object
|
|
17
|
+
? {
|
|
18
|
+
[K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K
|
|
19
|
+
}[Extract<keyof T, string>]
|
|
20
|
+
: never
|
|
21
|
+
// Replace keyof Document with DotPath<Document>
|
|
9
22
|
export interface DbOptions<Document extends DocType> {
|
|
10
|
-
readonly sorBy?: keyof Document
|
|
11
|
-
readonly order?: 'asc' | 'desc'
|
|
12
23
|
readonly tableName: string
|
|
13
|
-
readonly indexes?: Array<
|
|
24
|
+
readonly indexes?: Array<DotPath<Document>>
|
|
14
25
|
readonly backend: Backend
|
|
15
|
-
|
|
16
|
-
* Optional key. If omitted, the table uses implicit SQLite ROWID as the key.
|
|
17
|
-
*/
|
|
18
|
-
readonly key?: keyof Document
|
|
26
|
+
readonly key?: DotPath<Document>
|
|
19
27
|
readonly disablePragmaOptimization?: boolean
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
interface DbNotGeneric {
|
|
23
|
-
readonly backend: Backend
|
|
24
|
-
}
|
|
25
|
-
|
|
26
30
|
export interface SearchOptions<Document extends DocType, Selected = Document> {
|
|
27
|
-
readonly sortBy?:
|
|
31
|
+
readonly sortBy?: DotPath<Document>
|
|
28
32
|
readonly order?: 'asc' | 'desc'
|
|
29
33
|
readonly limit?: number
|
|
30
34
|
readonly offset?: number
|
|
31
35
|
readonly where?: Where<Document>
|
|
32
36
|
readonly stepSize?: number
|
|
33
|
-
/**
|
|
34
|
-
* Naive projection. Prefer specialized queries for heavy fan-out graphs.
|
|
35
|
-
*/
|
|
36
37
|
readonly select?: (document: Document, meta: { rowId: number }) => Selected
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
interface DbNotGeneric {
|
|
41
|
+
readonly backend: Backend
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
export type Key = string | number
|
|
40
45
|
|
|
41
46
|
export type MutationOp = 'insert' | 'update' | 'delete'
|
|
@@ -1,267 +1,182 @@
|
|
|
1
|
+
/* eslint-disable unicorn/no-array-callback-reference */
|
|
2
|
+
/* eslint-disable unicorn/no-nested-ternary */
|
|
3
|
+
/* eslint-disable no-nested-ternary */
|
|
1
4
|
/* eslint-disable sonarjs/no-nested-conditional */
|
|
2
5
|
/* eslint-disable sonarjs/cognitive-complexity */
|
|
6
|
+
|
|
3
7
|
// -------------------------------------------------------------
|
|
4
|
-
//
|
|
5
|
-
// *or* directly a `Document[K]`/`Document[K][]`, shorthand for "is"/"in".
|
|
6
|
-
// We also allow the special literal "KEY" to filter by the primary‐key column.
|
|
8
|
+
// Condition operators for each field
|
|
7
9
|
// -------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
readonly
|
|
11
|
-
readonly
|
|
10
|
+
interface Condition<T> {
|
|
11
|
+
readonly is?: T | T[]
|
|
12
|
+
readonly isNot?: T | T[]
|
|
13
|
+
readonly gt?: T
|
|
14
|
+
readonly gte?: T
|
|
15
|
+
readonly lt?: T
|
|
16
|
+
readonly lte?: T
|
|
17
|
+
readonly in?: T[]
|
|
18
|
+
readonly notIn?: T[]
|
|
19
|
+
readonly like?: T | T[]
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
readonly gte?: Document[K] | Array<Document[K]>
|
|
19
|
-
readonly lt?: Document[K] | Array<Document[K]>
|
|
20
|
-
readonly lte?: Document[K] | Array<Document[K]>
|
|
21
|
-
readonly in?: Document[K] | Array<Document[K]>
|
|
22
|
-
readonly notIn?: Document[K] | Array<Document[K]>
|
|
23
|
-
readonly like?: Document[K] | Array<Document[K]>
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* We extend `keyof Document` by the special literal "KEY".
|
|
28
|
-
* That means users can write `{ KEY: ... }` in addition to `{ someField: ... }`.
|
|
29
|
-
*
|
|
30
|
-
* - If K extends keyof Document, then primitive values must match Document[K].
|
|
31
|
-
* - If K === "KEY", then primitive values are treated as strings/Array<string>.
|
|
32
|
-
*/
|
|
33
|
-
export type Where<Document extends Record<string, unknown>> =
|
|
22
|
+
// -------------------------------------------------------------
|
|
23
|
+
// Where type: recursive object with operators or nested fields
|
|
24
|
+
// -------------------------------------------------------------
|
|
25
|
+
export type Where<T extends Record<string, unknown>> =
|
|
34
26
|
| {
|
|
35
|
-
[K in keyof
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
| (K extends keyof Document ? Array<Document[K]> : string[])
|
|
27
|
+
[K in keyof T]?: T[K] extends Record<string, unknown>
|
|
28
|
+
? Where<T[K]> // nested object
|
|
29
|
+
: Condition<T[K]> | T[K] | T[K][]
|
|
39
30
|
}
|
|
40
31
|
| {
|
|
41
|
-
readonly AND?: Array<Where<
|
|
42
|
-
readonly OR?: Array<Where<
|
|
43
|
-
readonly NOT?: Where<
|
|
32
|
+
readonly AND?: Array<Where<T>>
|
|
33
|
+
readonly OR?: Array<Where<T>>
|
|
34
|
+
readonly NOT?: Where<T>
|
|
44
35
|
}
|
|
45
36
|
|
|
46
37
|
// -------------------------------------------------------------
|
|
47
|
-
//
|
|
38
|
+
// Tiny helpers
|
|
48
39
|
// -------------------------------------------------------------
|
|
49
40
|
function inlineValue(value: unknown): string {
|
|
50
|
-
if (typeof value === 'string') {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return (value as number).toString()
|
|
55
|
-
}
|
|
56
|
-
if (typeof value === 'boolean') {
|
|
57
|
-
return (value as boolean) ? '1' : '0'
|
|
58
|
-
}
|
|
59
|
-
return `'${String(value).split("'").join("''")}'`
|
|
41
|
+
if (typeof value === 'string') return `'${value.replaceAll("'", "''")}'`
|
|
42
|
+
if (typeof value === 'number') return value.toString()
|
|
43
|
+
if (typeof value === 'boolean') return value ? '1' : '0'
|
|
44
|
+
return `'${String(value).replaceAll("'", "''")}'`
|
|
60
45
|
}
|
|
61
46
|
|
|
62
|
-
// -------------------------------------------------------------
|
|
63
|
-
// Build the expression for a given field.
|
|
64
|
-
// If field === "KEY", refer directly to the primary‐key column (`key`).
|
|
65
|
-
// Otherwise, extract from JSON `data`.
|
|
66
|
-
// -------------------------------------------------------------
|
|
67
47
|
function getFieldExpr(field: string, value: unknown, tableAlias?: string): string {
|
|
68
48
|
const prefix = tableAlias ? `${tableAlias}.` : ''
|
|
69
49
|
if (field === 'KEY') {
|
|
70
|
-
// Use double‐quotes around key to avoid conflicts with reserved words
|
|
71
50
|
return `"${prefix}key"`
|
|
72
51
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (typeof value === '
|
|
76
|
-
return `CAST(json_extract(${prefix}data, '$.${field}') AS TEXT)`
|
|
77
|
-
}
|
|
78
|
-
if (typeof value === 'boolean') {
|
|
79
|
-
return `CAST(json_extract(${prefix}data, '$.${field}') AS INTEGER)`
|
|
80
|
-
}
|
|
81
|
-
if (typeof value === 'number') {
|
|
82
|
-
return `CAST(json_extract(${prefix}data, '$.${field}') AS NUMERIC)`
|
|
83
|
-
}
|
|
52
|
+
if (typeof value === 'string') return `CAST(json_extract(${prefix}data, '$.${field}') AS TEXT)`
|
|
53
|
+
if (typeof value === 'number') return `CAST(json_extract(${prefix}data, '$.${field}') AS NUMERIC)`
|
|
54
|
+
if (typeof value === 'boolean') return `CAST(json_extract(${prefix}data, '$.${field}') AS INTEGER)`
|
|
84
55
|
return `json_extract(${prefix}data, '$.${field}')`
|
|
85
56
|
}
|
|
86
57
|
|
|
58
|
+
const OPS_SET: ReadonlySet<string> = new Set(['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'like'])
|
|
59
|
+
|
|
87
60
|
// -------------------------------------------------------------
|
|
88
|
-
//
|
|
61
|
+
// Flatten nested objects to dot-paths
|
|
89
62
|
// -------------------------------------------------------------
|
|
90
|
-
|
|
63
|
+
function flattenWhere(object: Record<string, unknown>, prefix = ''): Record<string, unknown> {
|
|
64
|
+
const result: Record<string, unknown> = {}
|
|
91
65
|
|
|
92
|
-
|
|
93
|
-
|
|
66
|
+
for (const [k, v] of Object.entries(object)) {
|
|
67
|
+
if (k === 'AND' || k === 'OR' || k === 'NOT') {
|
|
68
|
+
result[k] = v
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const path = prefix ? `${prefix}.${k}` : k
|
|
73
|
+
|
|
74
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).some((kk) => OPS_SET.has(kk))) {
|
|
75
|
+
Object.assign(result, flattenWhere(v as Record<string, unknown>, path))
|
|
76
|
+
} else {
|
|
77
|
+
result[path] = v
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result
|
|
94
82
|
}
|
|
95
83
|
|
|
96
84
|
// -------------------------------------------------------------
|
|
97
|
-
// Main recursive
|
|
98
|
-
// (without the leading "WHERE").
|
|
85
|
+
// Main recursive builder
|
|
99
86
|
// -------------------------------------------------------------
|
|
100
|
-
export function getWhere<
|
|
101
|
-
if (!where || typeof where !== 'object')
|
|
102
|
-
return ''
|
|
103
|
-
}
|
|
87
|
+
export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string): string {
|
|
88
|
+
if (!where || typeof where !== 'object') return ''
|
|
104
89
|
|
|
105
|
-
// ----- Logical branches
|
|
106
|
-
if (
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
let combined = ''
|
|
110
|
-
let firstAdded = false
|
|
111
|
-
for (const sub of array) {
|
|
112
|
-
const clause = getWhere(sub, tableAlias)
|
|
113
|
-
if (!clause) continue
|
|
114
|
-
if (firstAdded) combined += ' AND '
|
|
115
|
-
combined += clause
|
|
116
|
-
firstAdded = true
|
|
117
|
-
}
|
|
118
|
-
return firstAdded ? `(${combined})` : ''
|
|
119
|
-
}
|
|
120
|
-
return ''
|
|
90
|
+
// ----- Logical branches -----
|
|
91
|
+
if (where.AND) {
|
|
92
|
+
const clauses = Array.isArray(where.AND) ? where.AND.map((w) => getWhere(w, tableAlias)).filter(Boolean) : []
|
|
93
|
+
return clauses.length > 0 ? `(${clauses.join(' AND ')})` : ''
|
|
121
94
|
}
|
|
122
95
|
|
|
123
|
-
if (
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
let combined = ''
|
|
127
|
-
let firstAdded = false
|
|
128
|
-
for (const sub of array) {
|
|
129
|
-
const clause = getWhere(sub, tableAlias)
|
|
130
|
-
if (!clause) continue
|
|
131
|
-
if (firstAdded) combined += ' OR '
|
|
132
|
-
combined += clause
|
|
133
|
-
firstAdded = true
|
|
134
|
-
}
|
|
135
|
-
return firstAdded ? `(${combined})` : ''
|
|
136
|
-
}
|
|
137
|
-
return ''
|
|
96
|
+
if (where.OR) {
|
|
97
|
+
const clauses = Array.isArray(where.OR) ? where.OR.map((w) => getWhere(w, tableAlias)).filter(Boolean) : []
|
|
98
|
+
return clauses.length > 0 ? `(${clauses.join(' OR ')})` : ''
|
|
138
99
|
}
|
|
139
100
|
|
|
140
|
-
if (
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
const clause = getWhere(sub, tableAlias)
|
|
144
|
-
return clause ? `(NOT ${clause})` : ''
|
|
145
|
-
}
|
|
146
|
-
return ''
|
|
101
|
+
if (where.NOT) {
|
|
102
|
+
const clause = getWhere(where.NOT, tableAlias)
|
|
103
|
+
return clause ? `(NOT ${clause})` : ''
|
|
147
104
|
}
|
|
148
105
|
|
|
149
|
-
// ----- Field
|
|
106
|
+
// ----- Field conditions -----
|
|
107
|
+
const flat = flattenWhere(where as Record<string, unknown>)
|
|
150
108
|
let fieldClauses = ''
|
|
151
|
-
let
|
|
109
|
+
let anyField = false
|
|
152
110
|
|
|
153
|
-
for (const key
|
|
154
|
-
if (key === 'AND' || key === 'OR' || key === 'NOT') continue
|
|
155
|
-
|
|
156
|
-
const rawValue = (where as Record<string, unknown>)[key]
|
|
111
|
+
for (const [key, rawValue] of Object.entries(flat)) {
|
|
157
112
|
if (rawValue == null) continue
|
|
158
113
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
// - array → { in: rawVal }
|
|
162
|
-
let cond: Condition<Document, typeof key>
|
|
114
|
+
// coerce primitive/array into Condition
|
|
115
|
+
let cond: Condition<unknown>
|
|
163
116
|
if (typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
|
164
|
-
cond = Array.isArray(rawValue) ? { in: rawValue } :
|
|
117
|
+
cond = Array.isArray(rawValue) ? { in: rawValue } : { is: rawValue }
|
|
165
118
|
} else {
|
|
166
|
-
cond = rawValue as Condition<
|
|
119
|
+
cond = rawValue as Condition<unknown>
|
|
167
120
|
}
|
|
168
121
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (rawOpValue == null) continue
|
|
122
|
+
for (const opKey of Object.keys(cond)) {
|
|
123
|
+
if (!OPS_SET.has(opKey)) continue
|
|
124
|
+
const opValue = cond[opKey as keyof Condition<unknown>]
|
|
125
|
+
if (opValue == null) continue
|
|
174
126
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (array.length === 0) continue
|
|
127
|
+
const values = Array.isArray(opValue) ? opValue : [opValue]
|
|
128
|
+
if (values.length === 0) continue
|
|
178
129
|
|
|
179
|
-
//
|
|
130
|
+
// is / isNot / in / notIn
|
|
180
131
|
if (opKey === 'is' || opKey === 'isNot' || opKey === 'in' || opKey === 'notIn') {
|
|
181
|
-
const [
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
break
|
|
200
|
-
}
|
|
201
|
-
case 'isNot': {
|
|
202
|
-
fieldClauses +=
|
|
203
|
-
array.length > 1
|
|
204
|
-
? (anyFieldClause ? ' AND ' : '') + `${fieldExpr} NOT IN (${inList})`
|
|
205
|
-
: (anyFieldClause ? ' AND ' : '') + `${fieldExpr} <> ${inlineValue(array[0])}`
|
|
206
|
-
break
|
|
207
|
-
}
|
|
208
|
-
case 'in': {
|
|
209
|
-
fieldClauses +=
|
|
210
|
-
array.length > 1
|
|
211
|
-
? (anyFieldClause ? ' AND ' : '') + `${fieldExpr} IN (${inList})`
|
|
212
|
-
: (anyFieldClause ? ' AND ' : '') + `${fieldExpr} IN (${inlineValue(array[0])})`
|
|
213
|
-
break
|
|
214
|
-
}
|
|
215
|
-
case 'notIn': {
|
|
216
|
-
fieldClauses +=
|
|
217
|
-
array.length > 1
|
|
218
|
-
? (anyFieldClause ? ' AND ' : '') + `${fieldExpr} NOT IN (${inList})`
|
|
219
|
-
: (anyFieldClause ? ' AND ' : '') + `${fieldExpr} NOT IN (${inlineValue(array[0])})`
|
|
220
|
-
break
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
anyFieldClause = true
|
|
132
|
+
const fieldExpr = getFieldExpr(key, values[0], tableAlias)
|
|
133
|
+
const inList = values.map(inlineValue).join(',')
|
|
134
|
+
|
|
135
|
+
const clause =
|
|
136
|
+
opKey === 'is'
|
|
137
|
+
? values.length > 1
|
|
138
|
+
? `${fieldExpr} IN (${inList})`
|
|
139
|
+
: `${fieldExpr} = ${inlineValue(values[0])}`
|
|
140
|
+
: opKey === 'isNot'
|
|
141
|
+
? values.length > 1
|
|
142
|
+
? `${fieldExpr} NOT IN (${inList})`
|
|
143
|
+
: `${fieldExpr} <> ${inlineValue(values[0])}`
|
|
144
|
+
: opKey === 'in'
|
|
145
|
+
? `${fieldExpr} IN (${inList})`
|
|
146
|
+
: `${fieldExpr} NOT IN (${inList})`
|
|
147
|
+
|
|
148
|
+
fieldClauses += (anyField ? ' AND ' : '') + clause
|
|
149
|
+
anyField = true
|
|
225
150
|
continue
|
|
226
151
|
}
|
|
227
152
|
|
|
228
|
-
//
|
|
229
|
-
for (const v of
|
|
153
|
+
// gt / gte / lt / lte / like
|
|
154
|
+
for (const v of values) {
|
|
230
155
|
const fieldExpr = getFieldExpr(key, v, tableAlias)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
case 'lte': {
|
|
245
|
-
fieldClauses += (anyFieldClause ? ' AND ' : '') + `${fieldExpr} <= ${inlineValue(v)}`
|
|
246
|
-
break
|
|
247
|
-
}
|
|
248
|
-
case 'like': {
|
|
249
|
-
fieldClauses += (anyFieldClause ? ' AND ' : '') + `${fieldExpr} LIKE ${inlineValue(v)}`
|
|
250
|
-
break
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
anyFieldClause = true
|
|
156
|
+
const clause =
|
|
157
|
+
opKey === 'gt'
|
|
158
|
+
? `${fieldExpr} > ${inlineValue(v)}`
|
|
159
|
+
: opKey === 'gte'
|
|
160
|
+
? `${fieldExpr} >= ${inlineValue(v)}`
|
|
161
|
+
: opKey === 'lt'
|
|
162
|
+
? `${fieldExpr} < ${inlineValue(v)}`
|
|
163
|
+
: opKey === 'lte'
|
|
164
|
+
? `${fieldExpr} <= ${inlineValue(v)}`
|
|
165
|
+
: `${fieldExpr} LIKE ${inlineValue(v)}`
|
|
166
|
+
fieldClauses += (anyField ? ' AND ' : '') + clause
|
|
167
|
+
anyField = true
|
|
254
168
|
}
|
|
255
169
|
}
|
|
256
170
|
}
|
|
257
171
|
|
|
258
|
-
return
|
|
172
|
+
return anyField ? `(${fieldClauses})` : ''
|
|
259
173
|
}
|
|
260
174
|
|
|
261
175
|
// -------------------------------------------------------------
|
|
262
|
-
//
|
|
176
|
+
// Public wrapper: adds "WHERE" if needed
|
|
263
177
|
// -------------------------------------------------------------
|
|
264
|
-
export function getWhereQuery<
|
|
178
|
+
export function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>): string {
|
|
179
|
+
if (!where) return ''
|
|
265
180
|
const clause = getWhere(where)
|
|
266
181
|
return clause ? `WHERE ${clause}` : ''
|
|
267
182
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { Table, DbOptions, DocType } from './table.types';
|
|
2
2
|
export declare const DEFAULT_STEP_SIZE = 100;
|
|
3
|
+
export declare function getByPath<T extends object>(object: T, path: string): unknown;
|
|
3
4
|
export declare function createTable<Document extends DocType>(options: DbOptions<Document>): Promise<Table<Document>>;
|
|
@@ -4,35 +4,32 @@ export type DocType = {
|
|
|
4
4
|
[key: string]: any;
|
|
5
5
|
};
|
|
6
6
|
export type KeyTypeAvailable = 'string' | 'number';
|
|
7
|
+
export type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;
|
|
8
|
+
type Previous = [never, 0, 1, 2, 3, 4, 5];
|
|
9
|
+
export type DotPath<T, D extends number = 5> = [D] extends [never] ? never : T extends object ? {
|
|
10
|
+
[K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K;
|
|
11
|
+
}[Extract<keyof T, string>] : never;
|
|
7
12
|
export interface DbOptions<Document extends DocType> {
|
|
8
|
-
readonly sorBy?: keyof Document;
|
|
9
|
-
readonly order?: 'asc' | 'desc';
|
|
10
13
|
readonly tableName: string;
|
|
11
|
-
readonly indexes?: Array<
|
|
14
|
+
readonly indexes?: Array<DotPath<Document>>;
|
|
12
15
|
readonly backend: Backend;
|
|
13
|
-
|
|
14
|
-
* Optional key. If omitted, the table uses implicit SQLite ROWID as the key.
|
|
15
|
-
*/
|
|
16
|
-
readonly key?: keyof Document;
|
|
16
|
+
readonly key?: DotPath<Document>;
|
|
17
17
|
readonly disablePragmaOptimization?: boolean;
|
|
18
18
|
}
|
|
19
|
-
interface DbNotGeneric {
|
|
20
|
-
readonly backend: Backend;
|
|
21
|
-
}
|
|
22
19
|
export interface SearchOptions<Document extends DocType, Selected = Document> {
|
|
23
|
-
readonly sortBy?:
|
|
20
|
+
readonly sortBy?: DotPath<Document>;
|
|
24
21
|
readonly order?: 'asc' | 'desc';
|
|
25
22
|
readonly limit?: number;
|
|
26
23
|
readonly offset?: number;
|
|
27
24
|
readonly where?: Where<Document>;
|
|
28
25
|
readonly stepSize?: number;
|
|
29
|
-
/**
|
|
30
|
-
* Naive projection. Prefer specialized queries for heavy fan-out graphs.
|
|
31
|
-
*/
|
|
32
26
|
readonly select?: (document: Document, meta: {
|
|
33
27
|
rowId: number;
|
|
34
28
|
}) => Selected;
|
|
35
29
|
}
|
|
30
|
+
interface DbNotGeneric {
|
|
31
|
+
readonly backend: Backend;
|
|
32
|
+
}
|
|
36
33
|
export type Key = string | number;
|
|
37
34
|
export type MutationOp = 'insert' | 'update' | 'delete';
|
|
38
35
|
export interface MutationResult {
|
|
@@ -1,32 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
readonly
|
|
3
|
-
readonly
|
|
1
|
+
interface Condition<T> {
|
|
2
|
+
readonly is?: T | T[];
|
|
3
|
+
readonly isNot?: T | T[];
|
|
4
|
+
readonly gt?: T;
|
|
5
|
+
readonly gte?: T;
|
|
6
|
+
readonly lt?: T;
|
|
7
|
+
readonly lte?: T;
|
|
8
|
+
readonly in?: T[];
|
|
9
|
+
readonly notIn?: T[];
|
|
10
|
+
readonly like?: T | T[];
|
|
4
11
|
}
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
readonly isNot?: Document[K] | Array<Document[K]>;
|
|
8
|
-
readonly gt?: Document[K] | Array<Document[K]>;
|
|
9
|
-
readonly gte?: Document[K] | Array<Document[K]>;
|
|
10
|
-
readonly lt?: Document[K] | Array<Document[K]>;
|
|
11
|
-
readonly lte?: Document[K] | Array<Document[K]>;
|
|
12
|
-
readonly in?: Document[K] | Array<Document[K]>;
|
|
13
|
-
readonly notIn?: Document[K] | Array<Document[K]>;
|
|
14
|
-
readonly like?: Document[K] | Array<Document[K]>;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* We extend `keyof Document` by the special literal "KEY".
|
|
18
|
-
* That means users can write `{ KEY: ... }` in addition to `{ someField: ... }`.
|
|
19
|
-
*
|
|
20
|
-
* - If K extends keyof Document, then primitive values must match Document[K].
|
|
21
|
-
* - If K === "KEY", then primitive values are treated as strings/Array<string>.
|
|
22
|
-
*/
|
|
23
|
-
export type Where<Document extends Record<string, unknown>> = {
|
|
24
|
-
[K in keyof Document | 'KEY']?: Condition<Document, K extends keyof Document ? K : keyof Document> | (K extends keyof Document ? Document[K] : string) | (K extends keyof Document ? Array<Document[K]> : string[]);
|
|
12
|
+
export type Where<T extends Record<string, unknown>> = {
|
|
13
|
+
[K in keyof T]?: T[K] extends Record<string, unknown> ? Where<T[K]> : Condition<T[K]> | T[K] | T[K][];
|
|
25
14
|
} | {
|
|
26
|
-
readonly AND?: Array<Where<
|
|
27
|
-
readonly OR?: Array<Where<
|
|
28
|
-
readonly NOT?: Where<
|
|
15
|
+
readonly AND?: Array<Where<T>>;
|
|
16
|
+
readonly OR?: Array<Where<T>>;
|
|
17
|
+
readonly NOT?: Where<T>;
|
|
29
18
|
};
|
|
30
|
-
export declare function getWhere<
|
|
31
|
-
export declare function getWhereQuery<
|
|
19
|
+
export declare function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string): string;
|
|
20
|
+
export declare function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>): string;
|
|
32
21
|
export {};
|