@livestore/common 0.3.0-dev.23 → 0.3.0-dev.25

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 (117) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/adapter-types.d.ts +4 -2
  3. package/dist/adapter-types.d.ts.map +1 -1
  4. package/dist/adapter-types.js +1 -1
  5. package/dist/adapter-types.js.map +1 -1
  6. package/dist/derived-mutations.d.ts +8 -8
  7. package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
  8. package/dist/devtools/devtools-messages-common.d.ts +6 -6
  9. package/dist/devtools/devtools-messages-leader.d.ts +25 -24
  10. package/dist/devtools/devtools-messages-leader.d.ts.map +1 -1
  11. package/dist/leader-thread/LeaderSyncProcessor.d.ts +2 -1
  12. package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
  13. package/dist/leader-thread/LeaderSyncProcessor.js +16 -12
  14. package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
  15. package/dist/leader-thread/apply-mutation.js +1 -1
  16. package/dist/leader-thread/apply-mutation.js.map +1 -1
  17. package/dist/leader-thread/leader-worker-devtools.js +2 -2
  18. package/dist/leader-thread/leader-worker-devtools.js.map +1 -1
  19. package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
  20. package/dist/leader-thread/make-leader-thread-layer.js +3 -2
  21. package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
  22. package/dist/leader-thread/mutationlog.d.ts +1 -0
  23. package/dist/leader-thread/mutationlog.d.ts.map +1 -1
  24. package/dist/leader-thread/mutationlog.js +2 -1
  25. package/dist/leader-thread/mutationlog.js.map +1 -1
  26. package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
  27. package/dist/leader-thread/types.d.ts +1 -1
  28. package/dist/leader-thread/types.d.ts.map +1 -1
  29. package/dist/mutation.d.ts.map +1 -1
  30. package/dist/mutation.js +13 -2
  31. package/dist/mutation.js.map +1 -1
  32. package/dist/query-builder/api.d.ts +118 -20
  33. package/dist/query-builder/api.d.ts.map +1 -1
  34. package/dist/query-builder/api.js.map +1 -1
  35. package/dist/query-builder/astToSql.d.ts +7 -0
  36. package/dist/query-builder/astToSql.d.ts.map +1 -0
  37. package/dist/query-builder/astToSql.js +168 -0
  38. package/dist/query-builder/astToSql.js.map +1 -0
  39. package/dist/query-builder/impl.d.ts +1 -5
  40. package/dist/query-builder/impl.d.ts.map +1 -1
  41. package/dist/query-builder/impl.js +130 -96
  42. package/dist/query-builder/impl.js.map +1 -1
  43. package/dist/query-builder/impl.test.js +94 -0
  44. package/dist/query-builder/impl.test.js.map +1 -1
  45. package/dist/query-builder/mod.d.ts +7 -0
  46. package/dist/query-builder/mod.d.ts.map +1 -1
  47. package/dist/query-builder/mod.js +7 -0
  48. package/dist/query-builder/mod.js.map +1 -1
  49. package/dist/query-info.d.ts +4 -1
  50. package/dist/query-info.d.ts.map +1 -1
  51. package/dist/query-info.js.map +1 -1
  52. package/dist/rehydrate-from-mutationlog.js +1 -1
  53. package/dist/rehydrate-from-mutationlog.js.map +1 -1
  54. package/dist/schema/MutationEvent.d.ts +27 -10
  55. package/dist/schema/MutationEvent.d.ts.map +1 -1
  56. package/dist/schema/MutationEvent.js +24 -8
  57. package/dist/schema/MutationEvent.js.map +1 -1
  58. package/dist/schema/db-schema/dsl/mod.d.ts +7 -5
  59. package/dist/schema/db-schema/dsl/mod.d.ts.map +1 -1
  60. package/dist/schema/db-schema/dsl/mod.js +6 -0
  61. package/dist/schema/db-schema/dsl/mod.js.map +1 -1
  62. package/dist/schema/mutations.d.ts +12 -3
  63. package/dist/schema/mutations.d.ts.map +1 -1
  64. package/dist/schema/mutations.js.map +1 -1
  65. package/dist/schema/system-tables.d.ts +5 -5
  66. package/dist/schema/system-tables.d.ts.map +1 -1
  67. package/dist/schema/system-tables.js +1 -2
  68. package/dist/schema/system-tables.js.map +1 -1
  69. package/dist/schema/table-def.d.ts +7 -3
  70. package/dist/schema/table-def.d.ts.map +1 -1
  71. package/dist/schema/table-def.js +7 -1
  72. package/dist/schema/table-def.js.map +1 -1
  73. package/dist/sync/ClientSessionSyncProcessor.d.ts +2 -0
  74. package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
  75. package/dist/sync/ClientSessionSyncProcessor.js +8 -5
  76. package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
  77. package/dist/sync/next/rebase-events.d.ts +1 -1
  78. package/dist/sync/next/rebase-events.d.ts.map +1 -1
  79. package/dist/sync/sync.d.ts +19 -1
  80. package/dist/sync/sync.d.ts.map +1 -1
  81. package/dist/sync/sync.js.map +1 -1
  82. package/dist/sync/syncstate.d.ts +26 -4
  83. package/dist/sync/syncstate.d.ts.map +1 -1
  84. package/dist/sync/syncstate.js +95 -25
  85. package/dist/sync/syncstate.js.map +1 -1
  86. package/dist/sync/syncstate.test.js +60 -29
  87. package/dist/sync/syncstate.test.js.map +1 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +2 -2
  91. package/src/adapter-types.ts +4 -2
  92. package/src/leader-thread/LeaderSyncProcessor.ts +19 -13
  93. package/src/leader-thread/apply-mutation.ts +2 -2
  94. package/src/leader-thread/leader-worker-devtools.ts +2 -2
  95. package/src/leader-thread/make-leader-thread-layer.ts +3 -2
  96. package/src/leader-thread/mutationlog.ts +2 -1
  97. package/src/leader-thread/types.ts +1 -1
  98. package/src/mutation.ts +20 -3
  99. package/src/query-builder/api.ts +192 -15
  100. package/src/query-builder/astToSql.ts +203 -0
  101. package/src/query-builder/impl.test.ts +104 -0
  102. package/src/query-builder/impl.ts +157 -113
  103. package/src/query-builder/mod.ts +7 -0
  104. package/src/query-info.ts +6 -1
  105. package/src/rehydrate-from-mutationlog.ts +1 -1
  106. package/src/schema/MutationEvent.ts +28 -12
  107. package/src/schema/db-schema/dsl/mod.ts +30 -2
  108. package/src/schema/mutations.ts +12 -1
  109. package/src/schema/system-tables.ts +1 -2
  110. package/src/schema/table-def.ts +14 -4
  111. package/src/sync/ClientSessionSyncProcessor.ts +10 -4
  112. package/src/sync/next/rebase-events.ts +1 -1
  113. package/src/sync/sync.ts +19 -3
  114. package/src/sync/syncstate.test.ts +66 -32
  115. package/src/sync/syncstate.ts +116 -34
  116. package/src/version.ts +1 -1
  117. package/tmp/pack.tgz +0 -0
package/src/mutation.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { Schema } from '@livestore/utils/effect'
2
2
 
3
3
  import { SessionIdSymbol } from './adapter-types.js'
4
+ import type { QueryBuilder } from './query-builder/api.js'
5
+ import { isQueryBuilder } from './query-builder/api.js'
4
6
  import type * as MutationEvent from './schema/MutationEvent.js'
5
- import type { MutationDef } from './schema/mutations.js'
7
+ import type { MutationDef, MutationHandlerResult } from './schema/mutations.js'
8
+ import type { BindValues } from './sql-queries/sql-queries.js'
6
9
  import type { PreparedBindValues } from './util.js'
7
10
  import { prepareBindValues } from './util.js'
8
11
 
@@ -34,8 +37,22 @@ export const getExecArgsFromMutation = ({
34
37
  case 'function': {
35
38
  const mutationArgsDecoded =
36
39
  mutationEvent.decoded?.args ?? Schema.decodeUnknownSync(mutationDef.schema)(mutationEvent.encoded!.args)
37
- const res = mutationDef.sql(mutationArgsDecoded)
38
- statementRes = Array.isArray(res) ? res : [res]
40
+
41
+ const res = mutationDef.sql(mutationArgsDecoded, {
42
+ clientOnly: mutationDef.options.clientOnly,
43
+ // TODO properly implement this
44
+ currentFacts: new Map(),
45
+ })
46
+
47
+ statementRes = (Array.isArray(res) ? res : [res]).map((_: QueryBuilder.Any | MutationHandlerResult) => {
48
+ if (isQueryBuilder(_)) {
49
+ const { query, bindValues } = _.asSql()
50
+ return { sql: query, bindValues: bindValues as BindValues }
51
+ }
52
+
53
+ return _
54
+ })
55
+
39
56
  break
40
57
  }
41
58
  case 'string': {
@@ -7,10 +7,16 @@ import type { SqliteDsl } from '../schema/db-schema/mod.js'
7
7
  import type { DbSchema } from '../schema/mod.js'
8
8
  import type { SqlValue } from '../util.js'
9
9
 
10
- export type QueryBuilderAst = QueryBuilderAst.SelectQuery | QueryBuilderAst.CountQuery | QueryBuilderAst.RowQuery
10
+ export type QueryBuilderAst =
11
+ | QueryBuilderAst.SelectQuery
12
+ | QueryBuilderAst.CountQuery
13
+ | QueryBuilderAst.RowQuery
14
+ | QueryBuilderAst.InsertQuery
15
+ | QueryBuilderAst.UpdateQuery
16
+ | QueryBuilderAst.DeleteQuery
11
17
 
12
18
  export namespace QueryBuilderAst {
13
- export type SelectQuery = {
19
+ export interface SelectQuery {
14
20
  readonly _tag: 'SelectQuery'
15
21
  readonly columns: string[]
16
22
  readonly pickFirst: false | { fallback: () => any }
@@ -25,27 +31,67 @@ export namespace QueryBuilderAst {
25
31
  readonly resultSchemaSingle: Schema.Schema<any>
26
32
  }
27
33
 
28
- export type CountQuery = {
34
+ export interface CountQuery {
29
35
  readonly _tag: 'CountQuery'
30
36
  readonly tableDef: DbSchema.TableDefBase
31
37
  readonly where: ReadonlyArray<QueryBuilderAst.Where>
32
38
  readonly resultSchema: Schema.Schema<number, ReadonlyArray<{ count: number }>>
33
39
  }
34
40
 
35
- export type RowQuery = {
41
+ export interface RowQuery {
36
42
  readonly _tag: 'RowQuery'
37
43
  readonly tableDef: DbSchema.TableDefBase
38
44
  readonly id: string | SessionIdSymbol | number
39
45
  readonly insertValues: Record<string, unknown>
40
46
  }
41
47
 
42
- export type Where = {
48
+ export interface InsertQuery {
49
+ readonly _tag: 'InsertQuery'
50
+ readonly tableDef: DbSchema.TableDefBase
51
+ readonly values: Record<string, unknown>
52
+ readonly onConflict: OnConflict | undefined
53
+ readonly returning: string[] | undefined
54
+ readonly resultSchema: Schema.Schema<any>
55
+ }
56
+
57
+ export interface OnConflict {
58
+ /** Conflicting column name */
59
+ readonly target: string
60
+ readonly action:
61
+ | { readonly _tag: 'ignore' }
62
+ | { readonly _tag: 'replace' }
63
+ | {
64
+ readonly _tag: 'update'
65
+ readonly update: Record<string, unknown>
66
+ }
67
+ }
68
+
69
+ export interface UpdateQuery {
70
+ readonly _tag: 'UpdateQuery'
71
+ readonly tableDef: DbSchema.TableDefBase
72
+ readonly values: Record<string, unknown>
73
+ readonly where: ReadonlyArray<QueryBuilderAst.Where>
74
+ readonly returning: string[] | undefined
75
+ readonly resultSchema: Schema.Schema<any>
76
+ }
77
+
78
+ export interface DeleteQuery {
79
+ readonly _tag: 'DeleteQuery'
80
+ readonly tableDef: DbSchema.TableDefBase
81
+ readonly where: ReadonlyArray<QueryBuilderAst.Where>
82
+ readonly returning: string[] | undefined
83
+ readonly resultSchema: Schema.Schema<any>
84
+ }
85
+
86
+ export type WriteQuery = InsertQuery | UpdateQuery | DeleteQuery
87
+
88
+ export interface Where {
43
89
  readonly col: string
44
90
  readonly op: QueryBuilder.WhereOps
45
91
  readonly value: unknown
46
92
  }
47
93
 
48
- export type OrderBy = {
94
+ export interface OrderBy {
49
95
  readonly col: string
50
96
  readonly direction: 'asc' | 'desc'
51
97
  }
@@ -86,7 +132,20 @@ export namespace QueryBuilder {
86
132
  export type MultiValue = In
87
133
  }
88
134
 
89
- export type ApiFeature = 'select' | 'where' | 'count' | 'orderBy' | 'offset' | 'limit' | 'first' | 'row'
135
+ export type ApiFeature =
136
+ | 'select'
137
+ | 'where'
138
+ | 'count'
139
+ | 'orderBy'
140
+ | 'offset'
141
+ | 'limit'
142
+ | 'first'
143
+ | 'row'
144
+ | 'insert'
145
+ | 'update'
146
+ | 'delete'
147
+ | 'returning'
148
+ | 'onConflict'
90
149
 
91
150
  export type WhereParams<TTableDef extends DbSchema.TableDefBase> = Partial<{
92
151
  [K in keyof TTableDef['sqliteDef']['columns']]:
@@ -130,7 +189,7 @@ export namespace QueryBuilder {
130
189
  readonly [K in TColumn]: TTableDef['sqliteDef']['columns'][K]['schema']['Type']
131
190
  }>,
132
191
  TTableDef,
133
- TWithout | 'row' | 'select',
192
+ TWithout | 'row' | 'select' | 'returning' | 'onConflict',
134
193
  TQueryInfo
135
194
  >
136
195
  <TColumns extends keyof TTableDef['sqliteDef']['columns'] & string>(
@@ -142,7 +201,7 @@ export namespace QueryBuilder {
142
201
  readonly [K in TColumns]: TTableDef['sqliteDef']['columns'][K]['schema']['Type']
143
202
  }>,
144
203
  TTableDef,
145
- TWithout | 'row' | 'select' | 'count',
204
+ TWithout | 'row' | 'select' | 'count' | 'returning' | 'onConflict',
146
205
  TQueryInfo
147
206
  >
148
207
  }
@@ -187,7 +246,7 @@ export namespace QueryBuilder {
187
246
  readonly count: () => QueryBuilder<
188
247
  number,
189
248
  TTableDef,
190
- TWithout | 'row' | 'count' | 'select' | 'orderBy' | 'first' | 'offset' | 'limit',
249
+ TWithout | 'row' | 'count' | 'select' | 'orderBy' | 'first' | 'offset' | 'limit' | 'returning' | 'onConflict',
191
250
  TQueryInfo
192
251
  >
193
252
 
@@ -201,10 +260,10 @@ export namespace QueryBuilder {
201
260
  <TColName extends keyof TTableDef['sqliteDef']['columns'] & string>(
202
261
  col: TColName,
203
262
  direction: 'asc' | 'desc',
204
- ): QueryBuilder<TResult, TTableDef, TWithout, TQueryInfo>
263
+ ): QueryBuilder<TResult, TTableDef, TWithout | 'returning' | 'onConflict', TQueryInfo>
205
264
  <TParams extends QueryBuilder.OrderByParams<TTableDef>>(
206
265
  params: TParams,
207
- ): QueryBuilder<TResult, TTableDef, TWithout, TQueryInfo>
266
+ ): QueryBuilder<TResult, TTableDef, TWithout | 'returning' | 'onConflict', TQueryInfo>
208
267
  }
209
268
 
210
269
  /**
@@ -215,7 +274,12 @@ export namespace QueryBuilder {
215
274
  */
216
275
  readonly offset: (
217
276
  offset: number,
218
- ) => QueryBuilder<TResult, TTableDef, TWithout | 'row' | 'offset' | 'orderBy', TQueryInfo>
277
+ ) => QueryBuilder<
278
+ TResult,
279
+ TTableDef,
280
+ TWithout | 'row' | 'offset' | 'orderBy' | 'returning' | 'onConflict',
281
+ TQueryInfo
282
+ >
219
283
 
220
284
  /**
221
285
  * Example:
@@ -225,7 +289,12 @@ export namespace QueryBuilder {
225
289
  */
226
290
  readonly limit: (
227
291
  limit: number,
228
- ) => QueryBuilder<TResult, TTableDef, TWithout | 'row' | 'limit' | 'offset' | 'first' | 'orderBy', TQueryInfo>
292
+ ) => QueryBuilder<
293
+ TResult,
294
+ TTableDef,
295
+ TWithout | 'row' | 'limit' | 'offset' | 'first' | 'orderBy' | 'returning' | 'onConflict',
296
+ TQueryInfo
297
+ >
229
298
 
230
299
  /**
231
300
  * Example:
@@ -238,7 +307,7 @@ export namespace QueryBuilder {
238
307
  }) => QueryBuilder<
239
308
  TFallback | GetSingle<TResult>,
240
309
  TTableDef,
241
- TWithout | 'row' | 'first' | 'orderBy' | 'select' | 'limit' | 'offset' | 'where',
310
+ TWithout | 'row' | 'first' | 'orderBy' | 'select' | 'limit' | 'offset' | 'where' | 'returning' | 'onConflict',
242
311
  TQueryInfo
243
312
  >
244
313
 
@@ -258,6 +327,114 @@ export namespace QueryBuilder {
258
327
  id: string | SessionIdSymbol | number,
259
328
  opts: TOptions,
260
329
  ) => QueryBuilder<RowQuery.Result<TTableDef>, TTableDef, QueryBuilder.ApiFeature, QueryInfo.Row>
330
+
331
+ /**
332
+ * Insert a new row into the table
333
+ *
334
+ * Example:
335
+ * ```ts
336
+ * db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' })
337
+ * ```
338
+ */
339
+ readonly insert: (
340
+ values: TTableDef['insertSchema']['Type'],
341
+ ) => QueryBuilder<
342
+ TResult,
343
+ TTableDef,
344
+ TWithout | 'row' | 'select' | 'count' | 'orderBy' | 'first' | 'offset' | 'limit' | 'where',
345
+ QueryInfo.Write
346
+ >
347
+
348
+ /**
349
+ * Example: If the row already exists, it will be ignored.
350
+ * ```ts
351
+ * db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }).onConflict('id', 'ignore')
352
+ * ```
353
+ *
354
+ * Example: If the row already exists, it will be replaced.
355
+ * ```ts
356
+ * db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }).onConflict('id', 'replace')
357
+ * ```
358
+ *
359
+ * Example: If the row already exists, it will be updated.
360
+ * ```ts
361
+ * db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }).onConflict('id', 'update', { text: 'Buy soy milk' })
362
+ * ```
363
+ *
364
+ * NOTE This API doesn't yet support composite primary keys.
365
+ */
366
+ readonly onConflict: {
367
+ (
368
+ target: string,
369
+ action: 'ignore' | 'replace',
370
+ ): QueryBuilder<
371
+ TResult,
372
+ TTableDef,
373
+ TWithout | 'row' | 'select' | 'count' | 'orderBy' | 'first' | 'offset' | 'limit' | 'where',
374
+ TQueryInfo
375
+ >
376
+ <TTarget extends keyof TTableDef['sqliteDef']['columns'] & string>(
377
+ target: TTarget,
378
+ action: 'update',
379
+ updateValues: Partial<TTableDef['schema']['Type']>,
380
+ ): QueryBuilder<
381
+ TResult,
382
+ TTableDef,
383
+ TWithout | 'row' | 'select' | 'count' | 'orderBy' | 'first' | 'offset' | 'limit' | 'where',
384
+ TQueryInfo
385
+ >
386
+ }
387
+
388
+ /**
389
+ * Similar to the `.select` API but for write queries (insert, update, delete).
390
+ *
391
+ * Example:
392
+ * ```ts
393
+ * db.todos.insert({ id: '123', text: 'Buy milk', status: 'active' }).returning('id')
394
+ * ```
395
+ */
396
+ readonly returning: <TColumns extends keyof TTableDef['sqliteDef']['columns'] & string>(
397
+ ...columns: TColumns[]
398
+ ) => QueryBuilder<
399
+ ReadonlyArray<{
400
+ readonly [K in TColumns]: TTableDef['sqliteDef']['columns'][K]['schema']['Type']
401
+ }>,
402
+ TTableDef
403
+ >
404
+
405
+ /**
406
+ * Update rows in the table that match the where clause
407
+ *
408
+ * Example:
409
+ * ```ts
410
+ * db.todos.update({ status: 'completed' }).where({ id: '123' })
411
+ * ```
412
+ */
413
+ readonly update: (
414
+ values: Partial<TTableDef['schema']['Type']>,
415
+ ) => QueryBuilder<
416
+ TResult,
417
+ TTableDef,
418
+ TWithout | 'row' | 'select' | 'count' | 'orderBy' | 'first' | 'offset' | 'limit' | 'onConflict',
419
+ QueryInfo.Write
420
+ >
421
+
422
+ /**
423
+ * Delete rows from the table that match the where clause
424
+ *
425
+ * Example:
426
+ * ```ts
427
+ * db.todos.delete().where({ status: 'completed' })
428
+ * ```
429
+ *
430
+ * Note that it's generally recommended to do soft-deletes for synced apps.
431
+ */
432
+ readonly delete: () => QueryBuilder<
433
+ TResult,
434
+ TTableDef,
435
+ TWithout | 'row' | 'select' | 'count' | 'orderBy' | 'first' | 'offset' | 'limit' | 'onConflict',
436
+ QueryInfo.Write
437
+ >
261
438
  }
262
439
  }
263
440
 
@@ -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