@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.
Files changed (103) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +6 -0
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/adapter-types.js.map +1 -1
  5. package/dist/derived-mutations.d.ts +8 -8
  6. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  7. package/dist/devtools/devtools-messages-common.d.ts +6 -6
  8. package/dist/devtools/devtools-messages-leader.d.ts +25 -24
  9. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  10. package/dist/leader-thread/LeaderSyncProcessor.js +3 -1
  11. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  12. package/dist/leader-thread/apply-mutation.js +1 -1
  13. package/dist/leader-thread/apply-mutation.js.map +1 -1
  14. package/dist/leader-thread/leader-worker-devtools.js +2 -2
  15. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  16. package/dist/leader-thread/make-leader-thread-layer.d.ts +3 -2
  17. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  18. package/dist/leader-thread/make-leader-thread-layer.js +4 -2
  19. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  20. package/dist/leader-thread/mutationlog.js +1 -1
  21. package/dist/leader-thread/mutationlog.js.map +1 -1
  22. package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
  23. package/dist/leader-thread/types.d.ts +1 -1
  24. package/dist/leader-thread/types.d.ts.map +1 -1
  25. package/dist/mutation.d.ts.map +1 -1
  26. package/dist/mutation.js +13 -2
  27. package/dist/mutation.js.map +1 -1
  28. package/dist/query-builder/api.d.ts +118 -20
  29. package/dist/query-builder/api.d.ts.map +1 -1
  30. package/dist/query-builder/api.js.map +1 -1
  31. package/dist/query-builder/astToSql.d.ts +7 -0
  32. package/dist/query-builder/astToSql.d.ts.map +1 -0
  33. package/dist/query-builder/astToSql.js +168 -0
  34. package/dist/query-builder/astToSql.js.map +1 -0
  35. package/dist/query-builder/impl.d.ts +1 -5
  36. package/dist/query-builder/impl.d.ts.map +1 -1
  37. package/dist/query-builder/impl.js +130 -96
  38. package/dist/query-builder/impl.js.map +1 -1
  39. package/dist/query-builder/impl.test.js +94 -0
  40. package/dist/query-builder/impl.test.js.map +1 -1
  41. package/dist/query-builder/mod.d.ts +7 -0
  42. package/dist/query-builder/mod.d.ts.map +1 -1
  43. package/dist/query-builder/mod.js +7 -0
  44. package/dist/query-builder/mod.js.map +1 -1
  45. package/dist/query-info.d.ts +4 -1
  46. package/dist/query-info.d.ts.map +1 -1
  47. package/dist/query-info.js.map +1 -1
  48. package/dist/rehydrate-from-mutationlog.js +1 -1
  49. package/dist/rehydrate-from-mutationlog.js.map +1 -1
  50. package/dist/schema/MutationEvent.d.ts +10 -9
  51. package/dist/schema/MutationEvent.d.ts.map +1 -1
  52. package/dist/schema/MutationEvent.js +6 -6
  53. package/dist/schema/MutationEvent.js.map +1 -1
  54. package/dist/schema/db-schema/dsl/mod.d.ts +7 -5
  55. package/dist/schema/db-schema/dsl/mod.d.ts.map +1 -1
  56. package/dist/schema/db-schema/dsl/mod.js +6 -0
  57. package/dist/schema/db-schema/dsl/mod.js.map +1 -1
  58. package/dist/schema/mutations.d.ts +12 -3
  59. package/dist/schema/mutations.d.ts.map +1 -1
  60. package/dist/schema/mutations.js.map +1 -1
  61. package/dist/schema/system-tables.d.ts +5 -5
  62. package/dist/schema/system-tables.d.ts.map +1 -1
  63. package/dist/schema/system-tables.js +1 -2
  64. package/dist/schema/system-tables.js.map +1 -1
  65. package/dist/schema/table-def.d.ts +7 -3
  66. package/dist/schema/table-def.d.ts.map +1 -1
  67. package/dist/schema/table-def.js +7 -1
  68. package/dist/schema/table-def.js.map +1 -1
  69. package/dist/sync/next/rebase-events.d.ts +1 -1
  70. package/dist/sync/next/rebase-events.d.ts.map +1 -1
  71. package/dist/sync/sync.d.ts +10 -1
  72. package/dist/sync/sync.d.ts.map +1 -1
  73. package/dist/sync/sync.js.map +1 -1
  74. package/dist/sync/syncstate.test.js +1 -1
  75. package/dist/sync/syncstate.test.js.map +1 -1
  76. package/dist/version.d.ts +1 -1
  77. package/dist/version.js +1 -1
  78. package/package.json +2 -2
  79. package/src/adapter-types.ts +6 -0
  80. package/src/leader-thread/LeaderSyncProcessor.ts +3 -1
  81. package/src/leader-thread/apply-mutation.ts +2 -2
  82. package/src/leader-thread/leader-worker-devtools.ts +2 -2
  83. package/src/leader-thread/make-leader-thread-layer.ts +6 -2
  84. package/src/leader-thread/mutationlog.ts +1 -1
  85. package/src/leader-thread/types.ts +1 -1
  86. package/src/mutation.ts +20 -3
  87. package/src/query-builder/api.ts +192 -15
  88. package/src/query-builder/astToSql.ts +203 -0
  89. package/src/query-builder/impl.test.ts +104 -0
  90. package/src/query-builder/impl.ts +157 -113
  91. package/src/query-builder/mod.ts +7 -0
  92. package/src/query-info.ts +6 -1
  93. package/src/rehydrate-from-mutationlog.ts +1 -1
  94. package/src/schema/MutationEvent.ts +10 -10
  95. package/src/schema/db-schema/dsl/mod.ts +30 -2
  96. package/src/schema/mutations.ts +12 -1
  97. package/src/schema/system-tables.ts +1 -2
  98. package/src/schema/table-def.ts +14 -4
  99. package/src/sync/next/rebase-events.ts +1 -1
  100. package/src/sync/sync.ts +10 -1
  101. package/src/sync/syncstate.test.ts +1 -1
  102. package/src/version.ts +1 -1
  103. 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 { Option, Predicate, Schema } from '@livestore/utils/effect'
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
- assertQueryBuilderAst(ast)
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 (isRowQuery(ast)) return invalidQueryBuilder()
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
- return makeQueryBuilder(tableDef, {
54
- ...ast,
55
- where: [...ast.where, ...newOps],
56
- }) as any
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
- return makeQueryBuilder(tableDef, {
64
- ...ast,
65
- where: [...ast.where, { col, op, value }],
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
- assertQueryBuilderAst(ast)
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
- assertQueryBuilderAst(ast)
116
+ assertSelectQueryBuilderAst(ast)
93
117
 
94
118
  return makeQueryBuilder(tableDef, { ...ast, limit: Option.some(limit) })
95
119
  },
96
120
  offset: (offset) => {
97
- assertQueryBuilderAst(ast)
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
- ...ast,
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
- assertQueryBuilderAst(ast)
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
- _tag: 'SelectQuery',
173
- columns: [],
174
- pickFirst: false,
175
- select: { columns: [] },
176
- orderBy: [],
177
- offset: Option.none(),
178
- limit: Option.none(),
179
- tableDef,
180
- where: [],
181
- resultSchemaSingle: tableDef.schema,
182
- }) satisfies QueryBuilderAst
183
-
184
- const astToSql = (ast: QueryBuilderAst) => {
185
- if (isRowQuery(ast)) {
186
- // TODO
187
- return { query: `SELECT * FROM '${ast.tableDef.sqliteDef.name}' WHERE id = ?`, bindValues: [ast.id as TODO] }
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
- const bindValues: unknown[] = []
191
-
192
- // TODO bind values
193
- const whereStmt =
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 assertQueryBuilderAst(ast: QueryBuilderAst): asserts ast is QueryBuilderAst.SelectQuery {
256
- if (ast._tag !== 'SelectQuery') {
257
- throw new Error('Expected SelectQuery but got ' + ast._tag)
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
- if (queryAst._tag === 'SelectQuery') {
270
- const arraySchema = Schema.Array(queryAst.resultSchemaSingle)
271
- if (queryAst.pickFirst !== false) {
272
- return arraySchema.pipe(Schema.headOrElse(queryAst.pickFirst.fallback))
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
- return arraySchema
276
- } else if (queryAst._tag === 'CountQuery') {
277
- return Schema.Struct({ count: Schema.Number }).pipe(Schema.pluck('count'), Schema.Array, Schema.headOrElse())
278
- } else {
279
- if (queryAst.tableDef.options.isSingleColumn) {
280
- return queryAst.tableDef.schema.pipe(Schema.pluck('value'), Schema.Array, Schema.headOrElse())
281
- } else {
282
- return queryAst.tableDef.schema.pipe(Schema.Array, Schema.headOrElse())
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
  }
@@ -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
  */