@livestore/common 0.3.0-dev.22 → 0.3.0-dev.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapter-types.d.ts +6 -0
- package/dist/adapter-types.d.ts.map +1 -1
- package/dist/adapter-types.js.map +1 -1
- package/dist/derived-mutations.d.ts +8 -8
- package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
- package/dist/devtools/devtools-messages-common.d.ts +6 -6
- package/dist/devtools/devtools-messages-leader.d.ts +25 -24
- package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.js +3 -1
- package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
- package/dist/leader-thread/apply-mutation.js +1 -1
- package/dist/leader-thread/apply-mutation.js.map +1 -1
- package/dist/leader-thread/leader-worker-devtools.js +2 -2
- package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.d.ts +3 -2
- package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.js +4 -2
- package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
- package/dist/leader-thread/mutationlog.js +1 -1
- package/dist/leader-thread/mutationlog.js.map +1 -1
- package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
- package/dist/leader-thread/types.d.ts +1 -1
- package/dist/leader-thread/types.d.ts.map +1 -1
- package/dist/mutation.d.ts.map +1 -1
- package/dist/mutation.js +13 -2
- package/dist/mutation.js.map +1 -1
- package/dist/query-builder/api.d.ts +118 -20
- package/dist/query-builder/api.d.ts.map +1 -1
- package/dist/query-builder/api.js.map +1 -1
- package/dist/query-builder/astToSql.d.ts +7 -0
- package/dist/query-builder/astToSql.d.ts.map +1 -0
- package/dist/query-builder/astToSql.js +168 -0
- package/dist/query-builder/astToSql.js.map +1 -0
- package/dist/query-builder/impl.d.ts +1 -5
- package/dist/query-builder/impl.d.ts.map +1 -1
- package/dist/query-builder/impl.js +130 -96
- package/dist/query-builder/impl.js.map +1 -1
- package/dist/query-builder/impl.test.js +94 -0
- package/dist/query-builder/impl.test.js.map +1 -1
- package/dist/query-builder/mod.d.ts +7 -0
- package/dist/query-builder/mod.d.ts.map +1 -1
- package/dist/query-builder/mod.js +7 -0
- package/dist/query-builder/mod.js.map +1 -1
- package/dist/query-info.d.ts +4 -1
- package/dist/query-info.d.ts.map +1 -1
- package/dist/query-info.js.map +1 -1
- package/dist/rehydrate-from-mutationlog.js +1 -1
- package/dist/rehydrate-from-mutationlog.js.map +1 -1
- package/dist/schema/MutationEvent.d.ts +10 -9
- package/dist/schema/MutationEvent.d.ts.map +1 -1
- package/dist/schema/MutationEvent.js +6 -6
- package/dist/schema/MutationEvent.js.map +1 -1
- package/dist/schema/db-schema/dsl/mod.d.ts +7 -5
- package/dist/schema/db-schema/dsl/mod.d.ts.map +1 -1
- package/dist/schema/db-schema/dsl/mod.js +6 -0
- package/dist/schema/db-schema/dsl/mod.js.map +1 -1
- package/dist/schema/mutations.d.ts +12 -3
- package/dist/schema/mutations.d.ts.map +1 -1
- package/dist/schema/mutations.js.map +1 -1
- package/dist/schema/system-tables.d.ts +5 -5
- package/dist/schema/system-tables.d.ts.map +1 -1
- package/dist/schema/system-tables.js +1 -2
- package/dist/schema/system-tables.js.map +1 -1
- package/dist/schema/table-def.d.ts +7 -3
- package/dist/schema/table-def.d.ts.map +1 -1
- package/dist/schema/table-def.js +7 -1
- package/dist/schema/table-def.js.map +1 -1
- package/dist/sync/next/rebase-events.d.ts +1 -1
- package/dist/sync/next/rebase-events.d.ts.map +1 -1
- package/dist/sync/sync.d.ts +10 -1
- package/dist/sync/sync.d.ts.map +1 -1
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/syncstate.test.js +1 -1
- package/dist/sync/syncstate.test.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/adapter-types.ts +6 -0
- package/src/leader-thread/LeaderSyncProcessor.ts +3 -1
- package/src/leader-thread/apply-mutation.ts +2 -2
- package/src/leader-thread/leader-worker-devtools.ts +2 -2
- package/src/leader-thread/make-leader-thread-layer.ts +6 -2
- package/src/leader-thread/mutationlog.ts +1 -1
- package/src/leader-thread/types.ts +1 -1
- package/src/mutation.ts +20 -3
- package/src/query-builder/api.ts +192 -15
- package/src/query-builder/astToSql.ts +203 -0
- package/src/query-builder/impl.test.ts +104 -0
- package/src/query-builder/impl.ts +157 -113
- package/src/query-builder/mod.ts +7 -0
- package/src/query-info.ts +6 -1
- package/src/rehydrate-from-mutationlog.ts +1 -1
- package/src/schema/MutationEvent.ts +10 -10
- package/src/schema/db-schema/dsl/mod.ts +30 -2
- package/src/schema/mutations.ts +12 -1
- package/src/schema/system-tables.ts +1 -2
- package/src/schema/table-def.ts +14 -4
- package/src/sync/next/rebase-events.ts +1 -1
- package/src/sync/sync.ts +10 -1
- package/src/sync/syncstate.test.ts +1 -1
- package/src/version.ts +1 -1
- package/tmp/pack.tgz +0 -0
@@ -0,0 +1,203 @@
|
|
1
|
+
import { shouldNeverHappen } from '@livestore/utils'
|
2
|
+
import { Schema } from '@livestore/utils/effect'
|
3
|
+
|
4
|
+
import { SessionIdSymbol } from '../adapter-types.js'
|
5
|
+
import type { DbSchema } from '../schema/mod.js'
|
6
|
+
import type { SqlValue } from '../util.js'
|
7
|
+
import type { QueryBuilderAst } from './api.js'
|
8
|
+
|
9
|
+
// Helper functions for SQL generation
|
10
|
+
const formatWhereClause = (
|
11
|
+
whereConditions: ReadonlyArray<QueryBuilderAst.Where>,
|
12
|
+
tableDef: DbSchema.TableDefBase,
|
13
|
+
bindValues: SqlValue[],
|
14
|
+
): string => {
|
15
|
+
if (whereConditions.length === 0) return ''
|
16
|
+
|
17
|
+
const whereClause = whereConditions
|
18
|
+
.map(({ col, op, value }) => {
|
19
|
+
// Handle NULL values
|
20
|
+
if (value === null) {
|
21
|
+
if (op !== '=' && op !== '!=') {
|
22
|
+
throw new Error(`Unsupported operator for NULL value: ${op}`)
|
23
|
+
}
|
24
|
+
const opStmt = op === '=' ? 'IS' : 'IS NOT'
|
25
|
+
return `${col} ${opStmt} NULL`
|
26
|
+
}
|
27
|
+
|
28
|
+
// Get column definition and encode value
|
29
|
+
const colDef = tableDef.sqliteDef.columns[col]
|
30
|
+
if (colDef === undefined) {
|
31
|
+
throw new Error(`Column ${col} not found`)
|
32
|
+
}
|
33
|
+
|
34
|
+
// Handle array values for IN/NOT IN operators
|
35
|
+
const isArray = op === 'IN' || op === 'NOT IN'
|
36
|
+
|
37
|
+
if (isArray) {
|
38
|
+
// Verify value is an array
|
39
|
+
if (!Array.isArray(value)) {
|
40
|
+
return shouldNeverHappen(`Expected array value for ${op} operator but got`, value)
|
41
|
+
}
|
42
|
+
|
43
|
+
// Handle empty arrays
|
44
|
+
if (value.length === 0) {
|
45
|
+
return op === 'IN' ? '0=1' : '1=1'
|
46
|
+
}
|
47
|
+
|
48
|
+
const encodedValues = value.map((v) => Schema.encodeSync(colDef.schema)(v)) as SqlValue[]
|
49
|
+
bindValues.push(...encodedValues)
|
50
|
+
const placeholders = encodedValues.map(() => '?').join(', ')
|
51
|
+
return `${col} ${op} (${placeholders})`
|
52
|
+
} else {
|
53
|
+
const encodedValue = Schema.encodeSync(colDef.schema)(value)
|
54
|
+
bindValues.push(encodedValue as SqlValue)
|
55
|
+
return `${col} ${op} ?`
|
56
|
+
}
|
57
|
+
})
|
58
|
+
.join(' AND ')
|
59
|
+
|
60
|
+
return `WHERE ${whereClause}`
|
61
|
+
}
|
62
|
+
|
63
|
+
const formatReturningClause = (returning?: string[]): string => {
|
64
|
+
if (!returning || returning.length === 0) return ''
|
65
|
+
return ` RETURNING ${returning.join(', ')}`
|
66
|
+
}
|
67
|
+
|
68
|
+
export const astToSql = (ast: QueryBuilderAst): { query: string; bindValues: SqlValue[] } => {
|
69
|
+
const bindValues: SqlValue[] = []
|
70
|
+
|
71
|
+
// INSERT query
|
72
|
+
if (ast._tag === 'InsertQuery') {
|
73
|
+
const columns = Object.keys(ast.values)
|
74
|
+
const placeholders = columns.map(() => '?').join(', ')
|
75
|
+
const values = Object.values(Schema.encodeSync(ast.tableDef.insertSchema)(ast.values)) as SqlValue[]
|
76
|
+
|
77
|
+
bindValues.push(...values)
|
78
|
+
|
79
|
+
let query = `INSERT INTO '${ast.tableDef.sqliteDef.name}' (${columns.join(', ')}) VALUES (${placeholders})`
|
80
|
+
|
81
|
+
// Handle ON CONFLICT clause
|
82
|
+
if (ast.onConflict) {
|
83
|
+
query += ` ON CONFLICT (${ast.onConflict.target}) `
|
84
|
+
if (ast.onConflict.action._tag === 'ignore') {
|
85
|
+
query += 'DO NOTHING'
|
86
|
+
} else if (ast.onConflict.action._tag === 'replace') {
|
87
|
+
query += 'DO REPLACE'
|
88
|
+
} else {
|
89
|
+
// Handle the update record case
|
90
|
+
const updateValues = ast.onConflict.action.update
|
91
|
+
const updateCols = Object.keys(updateValues)
|
92
|
+
if (updateCols.length === 0) {
|
93
|
+
throw new Error('No update columns provided for ON CONFLICT DO UPDATE')
|
94
|
+
}
|
95
|
+
|
96
|
+
const updates = updateCols
|
97
|
+
.map((col) => {
|
98
|
+
const value = updateValues[col]
|
99
|
+
// If the value is undefined, use excluded.col
|
100
|
+
return value === undefined ? `${col} = excluded.${col}` : `${col} = ?`
|
101
|
+
})
|
102
|
+
.join(', ')
|
103
|
+
|
104
|
+
// Add values for the parameters
|
105
|
+
updateCols.forEach((col) => {
|
106
|
+
const value = updateValues[col]
|
107
|
+
if (value !== undefined) {
|
108
|
+
const colDef = ast.tableDef.sqliteDef.columns[col]
|
109
|
+
if (colDef === undefined) {
|
110
|
+
throw new Error(`Column ${col} not found`)
|
111
|
+
}
|
112
|
+
const encodedValue = Schema.encodeSync(colDef.schema)(value)
|
113
|
+
bindValues.push(encodedValue as SqlValue)
|
114
|
+
}
|
115
|
+
})
|
116
|
+
|
117
|
+
query += `DO UPDATE SET ${updates}`
|
118
|
+
}
|
119
|
+
}
|
120
|
+
|
121
|
+
query += formatReturningClause(ast.returning)
|
122
|
+
return { query, bindValues }
|
123
|
+
}
|
124
|
+
|
125
|
+
// UPDATE query
|
126
|
+
if (ast._tag === 'UpdateQuery') {
|
127
|
+
const setColumns = Object.keys(ast.values)
|
128
|
+
const setValues = Object.values(Schema.encodeSync(Schema.partial(ast.tableDef.schema))(ast.values))
|
129
|
+
bindValues.push(...setValues)
|
130
|
+
|
131
|
+
let query = `UPDATE '${ast.tableDef.sqliteDef.name}' SET ${setColumns.map((col) => `${col} = ?`).join(', ')}`
|
132
|
+
|
133
|
+
const whereClause = formatWhereClause(ast.where, ast.tableDef, bindValues)
|
134
|
+
if (whereClause) query += ` ${whereClause}`
|
135
|
+
|
136
|
+
query += formatReturningClause(ast.returning)
|
137
|
+
return { query, bindValues }
|
138
|
+
}
|
139
|
+
|
140
|
+
// DELETE query
|
141
|
+
if (ast._tag === 'DeleteQuery') {
|
142
|
+
let query = `DELETE FROM '${ast.tableDef.sqliteDef.name}'`
|
143
|
+
|
144
|
+
const whereClause = formatWhereClause(ast.where, ast.tableDef, bindValues)
|
145
|
+
if (whereClause) query += ` ${whereClause}`
|
146
|
+
|
147
|
+
query += formatReturningClause(ast.returning)
|
148
|
+
return { query, bindValues }
|
149
|
+
}
|
150
|
+
|
151
|
+
// COUNT query
|
152
|
+
if (ast._tag === 'CountQuery') {
|
153
|
+
const query = [
|
154
|
+
`SELECT COUNT(*) as count FROM '${ast.tableDef.sqliteDef.name}'`,
|
155
|
+
formatWhereClause(ast.where, ast.tableDef, bindValues),
|
156
|
+
]
|
157
|
+
.filter((clause) => clause.length > 0)
|
158
|
+
.join(' ')
|
159
|
+
|
160
|
+
return { query, bindValues }
|
161
|
+
}
|
162
|
+
|
163
|
+
// ROW query
|
164
|
+
if (ast._tag === 'RowQuery') {
|
165
|
+
// Handle the id value by encoding it with the id column schema
|
166
|
+
const idColDef = ast.tableDef.sqliteDef.columns.id
|
167
|
+
if (idColDef === undefined) {
|
168
|
+
throw new Error('Column id not found for ROW query')
|
169
|
+
}
|
170
|
+
|
171
|
+
// NOTE we're not encoding the id if it's the session id symbol, which needs to be taken care of by the caller
|
172
|
+
const encodedId = ast.id === SessionIdSymbol ? ast.id : Schema.encodeSync(idColDef.schema)(ast.id)
|
173
|
+
|
174
|
+
return {
|
175
|
+
query: `SELECT * FROM '${ast.tableDef.sqliteDef.name}' WHERE id = ?`,
|
176
|
+
bindValues: [encodedId as SqlValue],
|
177
|
+
}
|
178
|
+
}
|
179
|
+
|
180
|
+
// SELECT query
|
181
|
+
const columnsStmt = ast.select.columns.length === 0 ? '*' : ast.select.columns.join(', ')
|
182
|
+
const selectStmt = `SELECT ${columnsStmt}`
|
183
|
+
const fromStmt = `FROM '${ast.tableDef.sqliteDef.name}'`
|
184
|
+
const whereStmt = formatWhereClause(ast.where, ast.tableDef, bindValues)
|
185
|
+
|
186
|
+
const orderByStmt =
|
187
|
+
ast.orderBy.length > 0
|
188
|
+
? `ORDER BY ${ast.orderBy.map(({ col, direction }) => `${col} ${direction}`).join(', ')}`
|
189
|
+
: ''
|
190
|
+
|
191
|
+
const limitStmt = ast.limit._tag === 'Some' ? `LIMIT ?` : ''
|
192
|
+
if (ast.limit._tag === 'Some') bindValues.push(ast.limit.value)
|
193
|
+
|
194
|
+
const offsetStmt = ast.offset._tag === 'Some' ? `OFFSET ?` : ''
|
195
|
+
if (ast.offset._tag === 'Some') bindValues.push(ast.offset.value)
|
196
|
+
|
197
|
+
const query = [selectStmt, fromStmt, whereStmt, orderByStmt, offsetStmt, limitStmt]
|
198
|
+
.map((clause) => clause.trim())
|
199
|
+
.filter((clause) => clause.length > 0)
|
200
|
+
.join(' ')
|
201
|
+
|
202
|
+
return { query, bindValues }
|
203
|
+
}
|
@@ -226,6 +226,110 @@ describe('query builder', () => {
|
|
226
226
|
`)
|
227
227
|
})
|
228
228
|
})
|
229
|
+
|
230
|
+
describe('write operations', () => {
|
231
|
+
it('should handle INSERT queries', () => {
|
232
|
+
expect(db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }).asSql()).toMatchInlineSnapshot(`
|
233
|
+
{
|
234
|
+
"bindValues": [
|
235
|
+
"123",
|
236
|
+
"Buy milk",
|
237
|
+
"active",
|
238
|
+
],
|
239
|
+
"query": "INSERT INTO 'todos' (id, text, status) VALUES (?, ?, ?)",
|
240
|
+
}
|
241
|
+
`)
|
242
|
+
})
|
243
|
+
|
244
|
+
it('should handle UPDATE queries', () => {
|
245
|
+
expect(db.todos.update({ status: 'completed' }).where({ id: '123' }).asSql()).toMatchInlineSnapshot(`
|
246
|
+
{
|
247
|
+
"bindValues": [
|
248
|
+
"completed",
|
249
|
+
"123",
|
250
|
+
],
|
251
|
+
"query": "UPDATE 'todos' SET status = ? WHERE id = ?",
|
252
|
+
}
|
253
|
+
`)
|
254
|
+
})
|
255
|
+
|
256
|
+
it('should handle DELETE queries', () => {
|
257
|
+
expect(db.todos.delete().where({ status: 'completed' }).asSql()).toMatchInlineSnapshot(`
|
258
|
+
{
|
259
|
+
"bindValues": [
|
260
|
+
"completed",
|
261
|
+
],
|
262
|
+
"query": "DELETE FROM 'todos' WHERE status = ?",
|
263
|
+
}
|
264
|
+
`)
|
265
|
+
})
|
266
|
+
|
267
|
+
it('should handle INSERT with ON CONFLICT', () => {
|
268
|
+
expect(db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }).onConflict('id', 'ignore').asSql())
|
269
|
+
.toMatchInlineSnapshot(`
|
270
|
+
{
|
271
|
+
"bindValues": [
|
272
|
+
"123",
|
273
|
+
"Buy milk",
|
274
|
+
"active",
|
275
|
+
],
|
276
|
+
"query": "INSERT INTO 'todos' (id, text, status) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
|
277
|
+
}
|
278
|
+
`)
|
279
|
+
|
280
|
+
expect(
|
281
|
+
db.todos
|
282
|
+
.insert({ id: '123', text: 'Buy milk', status: 'active' })
|
283
|
+
.onConflict('id', 'update', { text: 'Buy soy milk', status: 'active' })
|
284
|
+
.asSql(),
|
285
|
+
).toMatchInlineSnapshot(`
|
286
|
+
{
|
287
|
+
"bindValues": [
|
288
|
+
"123",
|
289
|
+
"Buy milk",
|
290
|
+
"active",
|
291
|
+
"Buy soy milk",
|
292
|
+
"active",
|
293
|
+
],
|
294
|
+
"query": "INSERT INTO 'todos' (id, text, status) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET text = ?, status = ?",
|
295
|
+
}
|
296
|
+
`)
|
297
|
+
})
|
298
|
+
|
299
|
+
it('should handle RETURNING clause', () => {
|
300
|
+
expect(db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }).returning('id').asSql())
|
301
|
+
.toMatchInlineSnapshot(`
|
302
|
+
{
|
303
|
+
"bindValues": [
|
304
|
+
"123",
|
305
|
+
"Buy milk",
|
306
|
+
"active",
|
307
|
+
],
|
308
|
+
"query": "INSERT INTO 'todos' (id, text, status) VALUES (?, ?, ?) RETURNING id",
|
309
|
+
}
|
310
|
+
`)
|
311
|
+
|
312
|
+
expect(db.todos.update({ status: 'completed' }).where({ id: '123' }).returning('id').asSql())
|
313
|
+
.toMatchInlineSnapshot(`
|
314
|
+
{
|
315
|
+
"bindValues": [
|
316
|
+
"completed",
|
317
|
+
"123",
|
318
|
+
],
|
319
|
+
"query": "UPDATE 'todos' SET status = ? WHERE id = ? RETURNING id",
|
320
|
+
}
|
321
|
+
`)
|
322
|
+
|
323
|
+
expect(db.todos.delete().where({ status: 'completed' }).returning('id').asSql()).toMatchInlineSnapshot(`
|
324
|
+
{
|
325
|
+
"bindValues": [
|
326
|
+
"completed",
|
327
|
+
],
|
328
|
+
"query": "DELETE FROM 'todos' WHERE status = ? RETURNING id",
|
329
|
+
}
|
330
|
+
`)
|
331
|
+
})
|
332
|
+
})
|
229
333
|
})
|
230
334
|
|
231
335
|
// TODO nested queries
|
@@ -1,9 +1,11 @@
|
|
1
|
-
import {
|
1
|
+
import { casesHandled } from '@livestore/utils'
|
2
|
+
import { Match, Option, Predicate, Schema } from '@livestore/utils/effect'
|
2
3
|
|
3
4
|
import type { QueryInfo } from '../query-info.js'
|
4
5
|
import type { DbSchema } from '../schema/mod.js'
|
5
6
|
import type { QueryBuilder, QueryBuilderAst } from './api.js'
|
6
7
|
import { QueryBuilderAstSymbol, TypeId } from './api.js'
|
8
|
+
import { astToSql } from './astToSql.js'
|
7
9
|
|
8
10
|
export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBase>(
|
9
11
|
tableDef: TTableDef,
|
@@ -12,7 +14,7 @@ export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBas
|
|
12
14
|
const api = {
|
13
15
|
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
14
16
|
select() {
|
15
|
-
|
17
|
+
assertSelectQueryBuilderAst(ast)
|
16
18
|
|
17
19
|
// eslint-disable-next-line prefer-rest-params
|
18
20
|
const params = [...arguments]
|
@@ -36,8 +38,9 @@ export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBas
|
|
36
38
|
}) as any
|
37
39
|
},
|
38
40
|
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
39
|
-
where() {
|
40
|
-
if (
|
41
|
+
where: function () {
|
42
|
+
if (ast._tag === 'InsertQuery') return invalidQueryBuilder('Cannot use where with insert')
|
43
|
+
if (ast._tag === 'RowQuery') return invalidQueryBuilder('Cannot use where with row')
|
41
44
|
|
42
45
|
if (arguments.length === 1) {
|
43
46
|
// eslint-disable-next-line prefer-rest-params
|
@@ -50,24 +53,45 @@ export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBas
|
|
50
53
|
: { col, op: '=', value },
|
51
54
|
)
|
52
55
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
56
|
+
switch (ast._tag) {
|
57
|
+
case 'CountQuery':
|
58
|
+
case 'SelectQuery':
|
59
|
+
case 'UpdateQuery':
|
60
|
+
case 'DeleteQuery': {
|
61
|
+
return makeQueryBuilder(tableDef, {
|
62
|
+
...ast,
|
63
|
+
where: [...ast.where, ...newOps],
|
64
|
+
}) as any
|
65
|
+
}
|
66
|
+
default: {
|
67
|
+
return casesHandled(ast)
|
68
|
+
}
|
69
|
+
}
|
57
70
|
}
|
58
71
|
|
59
72
|
// eslint-disable-next-line prefer-rest-params
|
60
73
|
const [col, opOrValue, valueOrUndefined] = arguments
|
61
74
|
const op = valueOrUndefined === undefined ? '=' : opOrValue
|
62
75
|
const value = valueOrUndefined === undefined ? opOrValue : valueOrUndefined
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
76
|
+
|
77
|
+
switch (ast._tag) {
|
78
|
+
case 'CountQuery':
|
79
|
+
case 'SelectQuery':
|
80
|
+
case 'UpdateQuery':
|
81
|
+
case 'DeleteQuery': {
|
82
|
+
return makeQueryBuilder(tableDef, {
|
83
|
+
...ast,
|
84
|
+
where: [...ast.where, { col, op, value }],
|
85
|
+
}) as any
|
86
|
+
}
|
87
|
+
default: {
|
88
|
+
return casesHandled(ast)
|
89
|
+
}
|
90
|
+
}
|
67
91
|
},
|
68
92
|
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
69
93
|
orderBy() {
|
70
|
-
|
94
|
+
assertSelectQueryBuilderAst(ast)
|
71
95
|
|
72
96
|
if (arguments.length === 0 || arguments.length > 2) return invalidQueryBuilder()
|
73
97
|
|
@@ -89,12 +113,12 @@ export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBas
|
|
89
113
|
}) as any
|
90
114
|
},
|
91
115
|
limit: (limit) => {
|
92
|
-
|
116
|
+
assertSelectQueryBuilderAst(ast)
|
93
117
|
|
94
118
|
return makeQueryBuilder(tableDef, { ...ast, limit: Option.some(limit) })
|
95
119
|
},
|
96
120
|
offset: (offset) => {
|
97
|
-
|
121
|
+
assertSelectQueryBuilderAst(ast)
|
98
122
|
|
99
123
|
return makeQueryBuilder(tableDef, { ...ast, offset: Option.some(offset) })
|
100
124
|
},
|
@@ -102,17 +126,18 @@ export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBas
|
|
102
126
|
if (isRowQuery(ast)) return invalidQueryBuilder()
|
103
127
|
|
104
128
|
return makeQueryBuilder(tableDef, {
|
105
|
-
|
129
|
+
_tag: 'CountQuery',
|
130
|
+
tableDef,
|
131
|
+
where: [],
|
106
132
|
resultSchema: Schema.Struct({ count: Schema.Number }).pipe(
|
107
133
|
Schema.pluck('count'),
|
108
134
|
Schema.Array,
|
109
135
|
Schema.headOrElse(),
|
110
136
|
),
|
111
|
-
_tag: 'CountQuery',
|
112
137
|
})
|
113
138
|
},
|
114
139
|
first: (options) => {
|
115
|
-
|
140
|
+
assertSelectQueryBuilderAst(ast)
|
116
141
|
|
117
142
|
if (ast.limit._tag === 'Some') return invalidQueryBuilder(`.first() can't be called after .limit()`)
|
118
143
|
|
@@ -149,6 +174,65 @@ export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBas
|
|
149
174
|
insertValues,
|
150
175
|
}) as any
|
151
176
|
},
|
177
|
+
insert: (values) => {
|
178
|
+
return makeQueryBuilder(tableDef, {
|
179
|
+
_tag: 'InsertQuery',
|
180
|
+
tableDef,
|
181
|
+
values: values as any,
|
182
|
+
onConflict: undefined,
|
183
|
+
returning: undefined,
|
184
|
+
resultSchema: Schema.Void,
|
185
|
+
}) as any
|
186
|
+
},
|
187
|
+
onConflict: (target: string, action: 'ignore' | 'replace' | 'update', updateValues?: Record<string, unknown>) => {
|
188
|
+
assertInsertQueryBuilderAst(ast)
|
189
|
+
|
190
|
+
const onConflict = Match.value(action).pipe(
|
191
|
+
Match.when('ignore', () => ({ target, action: { _tag: 'ignore' } }) satisfies QueryBuilderAst.OnConflict),
|
192
|
+
Match.when('replace', () => ({ target, action: { _tag: 'replace' } }) satisfies QueryBuilderAst.OnConflict),
|
193
|
+
Match.when(
|
194
|
+
'update',
|
195
|
+
() => ({ target, action: { _tag: 'update', update: updateValues! } }) satisfies QueryBuilderAst.OnConflict,
|
196
|
+
),
|
197
|
+
Match.exhaustive,
|
198
|
+
)
|
199
|
+
|
200
|
+
return makeQueryBuilder(tableDef, {
|
201
|
+
...ast,
|
202
|
+
onConflict,
|
203
|
+
}) as any
|
204
|
+
},
|
205
|
+
|
206
|
+
returning: (...columns) => {
|
207
|
+
assertWriteQueryBuilderAst(ast)
|
208
|
+
|
209
|
+
return makeQueryBuilder(tableDef, {
|
210
|
+
...ast,
|
211
|
+
returning: columns,
|
212
|
+
resultSchema: tableDef.schema.pipe(Schema.pick(...columns), Schema.Array),
|
213
|
+
}) as any
|
214
|
+
},
|
215
|
+
|
216
|
+
update: (values) => {
|
217
|
+
return makeQueryBuilder(tableDef, {
|
218
|
+
_tag: 'UpdateQuery',
|
219
|
+
tableDef,
|
220
|
+
values: values as any,
|
221
|
+
where: [],
|
222
|
+
returning: undefined,
|
223
|
+
resultSchema: Schema.Void,
|
224
|
+
}) as any
|
225
|
+
},
|
226
|
+
|
227
|
+
delete: () => {
|
228
|
+
return makeQueryBuilder(tableDef, {
|
229
|
+
_tag: 'DeleteQuery',
|
230
|
+
tableDef,
|
231
|
+
where: [],
|
232
|
+
returning: undefined,
|
233
|
+
resultSchema: Schema.Void,
|
234
|
+
}) as any
|
235
|
+
},
|
152
236
|
} satisfies QueryBuilder.ApiFull<TResult, TTableDef, never, QueryInfo.None>
|
153
237
|
|
154
238
|
return {
|
@@ -167,94 +251,38 @@ export const makeQueryBuilder = <TResult, TTableDef extends DbSchema.TableDefBas
|
|
167
251
|
} satisfies QueryBuilder<TResult, TTableDef>
|
168
252
|
}
|
169
253
|
|
170
|
-
const emptyAst = (tableDef: DbSchema.TableDefBase) =>
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
254
|
+
const emptyAst = (tableDef: DbSchema.TableDefBase): QueryBuilderAst.SelectQuery => ({
|
255
|
+
_tag: 'SelectQuery',
|
256
|
+
columns: [],
|
257
|
+
pickFirst: false,
|
258
|
+
select: { columns: [] },
|
259
|
+
orderBy: [],
|
260
|
+
offset: Option.none(),
|
261
|
+
limit: Option.none(),
|
262
|
+
tableDef,
|
263
|
+
where: [],
|
264
|
+
resultSchemaSingle: tableDef.schema,
|
265
|
+
})
|
266
|
+
|
267
|
+
// Helper functions
|
268
|
+
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
269
|
+
function assertSelectQueryBuilderAst(ast: QueryBuilderAst): asserts ast is QueryBuilderAst.SelectQuery {
|
270
|
+
if (ast._tag !== 'SelectQuery') {
|
271
|
+
throw new Error('Expected SelectQuery but got ' + ast._tag)
|
188
272
|
}
|
273
|
+
}
|
189
274
|
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
ast.where.length > 0
|
195
|
-
? `WHERE ${ast.where
|
196
|
-
.map(({ col, op, value }) => {
|
197
|
-
if (value === null) {
|
198
|
-
if (op !== '=' && op !== '!=') {
|
199
|
-
throw new Error(`Unsupported operator for NULL value: ${op}`)
|
200
|
-
}
|
201
|
-
const opStmt = op === '=' ? 'IS' : 'IS NOT'
|
202
|
-
return `${col} ${opStmt} NULL`
|
203
|
-
} else {
|
204
|
-
const colDef = ast.tableDef.sqliteDef.columns[col]
|
205
|
-
if (colDef === undefined) {
|
206
|
-
throw new Error(`Column ${col} not found`)
|
207
|
-
}
|
208
|
-
const isArray = op === 'IN' || op === 'NOT IN'
|
209
|
-
const colSchema = isArray ? Schema.Array(colDef.schema) : colDef.schema
|
210
|
-
const encodedValue = Schema.encodeSync(colSchema)(value)
|
211
|
-
|
212
|
-
if (isArray) {
|
213
|
-
bindValues.push(...encodedValue)
|
214
|
-
const placeholders = Array.from({ length: encodedValue.length }, () => '?').join(', ')
|
215
|
-
return `${col} ${op} (${placeholders})`
|
216
|
-
} else {
|
217
|
-
bindValues.push(encodedValue)
|
218
|
-
return `${col} ${op} ?`
|
219
|
-
}
|
220
|
-
}
|
221
|
-
})
|
222
|
-
.join(' AND ')}`
|
223
|
-
: ''
|
224
|
-
|
225
|
-
if (ast._tag === 'CountQuery') {
|
226
|
-
const selectFromStmt = `SELECT COUNT(*) as count FROM '${ast.tableDef.sqliteDef.name}'`
|
227
|
-
const query = [selectFromStmt, whereStmt].filter((_) => _.length > 0).join(' ')
|
228
|
-
return { query, bindValues }
|
275
|
+
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
276
|
+
function assertInsertQueryBuilderAst(ast: QueryBuilderAst): asserts ast is QueryBuilderAst.InsertQuery {
|
277
|
+
if (ast._tag !== 'InsertQuery') {
|
278
|
+
throw new Error('Expected InsertQuery but got ' + ast._tag)
|
229
279
|
}
|
230
|
-
const columnsStmt = ast.select.columns.length === 0 ? '*' : ast.select.columns.join(', ')
|
231
|
-
const selectStmt = `SELECT ${columnsStmt}`
|
232
|
-
const fromStmt = `FROM '${ast.tableDef.sqliteDef.name}'`
|
233
|
-
|
234
|
-
const orderByStmt =
|
235
|
-
ast.orderBy.length > 0
|
236
|
-
? `ORDER BY ${ast.orderBy.map(({ col, direction }) => `${col} ${direction}`).join(', ')}`
|
237
|
-
: ''
|
238
|
-
|
239
|
-
const limitStmt = ast.limit._tag === 'Some' ? `LIMIT ?` : ''
|
240
|
-
if (ast.limit._tag === 'Some') bindValues.push(ast.limit.value)
|
241
|
-
|
242
|
-
const offsetStmt = ast.offset._tag === 'Some' ? `OFFSET ?` : ''
|
243
|
-
if (ast.offset._tag === 'Some') bindValues.push(ast.offset.value)
|
244
|
-
|
245
|
-
const query = [selectStmt, fromStmt, whereStmt, orderByStmt, offsetStmt, limitStmt]
|
246
|
-
.map((_) => _.trim())
|
247
|
-
.filter((_) => _.length > 0)
|
248
|
-
.join(' ')
|
249
|
-
|
250
|
-
// TODO bind values
|
251
|
-
return { query, bindValues }
|
252
280
|
}
|
253
281
|
|
254
282
|
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
255
|
-
function
|
256
|
-
if (ast._tag !== '
|
257
|
-
throw new Error('Expected
|
283
|
+
function assertWriteQueryBuilderAst(ast: QueryBuilderAst): asserts ast is QueryBuilderAst.WriteQuery {
|
284
|
+
if (ast._tag !== 'InsertQuery' && ast._tag !== 'UpdateQuery' && ast._tag !== 'DeleteQuery') {
|
285
|
+
throw new Error('Expected WriteQuery but got ' + ast._tag)
|
258
286
|
}
|
259
287
|
}
|
260
288
|
|
@@ -264,22 +292,38 @@ export const invalidQueryBuilder = (msg?: string) => {
|
|
264
292
|
throw new Error('Invalid query builder' + (msg ? `: ${msg}` : ''))
|
265
293
|
}
|
266
294
|
|
267
|
-
export const getResultSchema = (qb: QueryBuilder<any, any, any>) => {
|
295
|
+
export const getResultSchema = (qb: QueryBuilder<any, any, any>): Schema.Schema<any> => {
|
268
296
|
const queryAst = qb[QueryBuilderAstSymbol]
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
297
|
+
switch (queryAst._tag) {
|
298
|
+
case 'SelectQuery': {
|
299
|
+
const arraySchema = Schema.Array(queryAst.resultSchemaSingle)
|
300
|
+
if (queryAst.pickFirst !== false) {
|
301
|
+
return arraySchema.pipe(Schema.headOrElse(queryAst.pickFirst.fallback))
|
302
|
+
}
|
303
|
+
|
304
|
+
return arraySchema
|
273
305
|
}
|
306
|
+
case 'CountQuery': {
|
307
|
+
return Schema.Struct({ count: Schema.Number }).pipe(Schema.pluck('count'), Schema.Array, Schema.headOrElse())
|
308
|
+
}
|
309
|
+
case 'InsertQuery':
|
310
|
+
case 'UpdateQuery':
|
311
|
+
case 'DeleteQuery': {
|
312
|
+
// For write operations with RETURNING clause, we need to return the appropriate schema
|
313
|
+
if (queryAst.returning && queryAst.returning.length > 0) {
|
314
|
+
// Create a schema for the returned columns
|
315
|
+
return queryAst.tableDef.schema.pipe(Schema.pick(...queryAst.returning), Schema.Array)
|
316
|
+
}
|
274
317
|
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
318
|
+
// For write operations without RETURNING, the result is the number of affected rows
|
319
|
+
return Schema.Number
|
320
|
+
}
|
321
|
+
default: {
|
322
|
+
if (queryAst.tableDef.options.isSingleColumn) {
|
323
|
+
return queryAst.tableDef.schema.pipe(Schema.pluck('value'), Schema.Array, Schema.headOrElse())
|
324
|
+
} else {
|
325
|
+
return queryAst.tableDef.schema.pipe(Schema.Array, Schema.headOrElse())
|
326
|
+
}
|
283
327
|
}
|
284
328
|
}
|
285
329
|
}
|
package/src/query-builder/mod.ts
CHANGED
@@ -7,4 +7,11 @@ export * from './impl.js'
|
|
7
7
|
* - Close abstraction to SQLite to provide a simple & convenient API with predictable behaviour
|
8
8
|
* - Use table schema definitions to parse, map & validate query results
|
9
9
|
* - Implementation detail: Separate type-level & AST-based runtime implementation
|
10
|
+
*
|
11
|
+
* Currently not supported (not exhaustive list):
|
12
|
+
* - Assumes a `id` column as primary key
|
13
|
+
* - Composite primary keys
|
14
|
+
*
|
15
|
+
* Other known limitations
|
16
|
+
* - Doesn't exclude all invalid query patterns on type level `e.g. `db.todos.returning('id')`
|
10
17
|
*/
|