@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapter-types.d.ts +4 -2
- package/dist/adapter-types.d.ts.map +1 -1
- package/dist/adapter-types.js +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.d.ts +2 -1
- package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.js +16 -12
- 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.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.js +3 -2
- package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
- package/dist/leader-thread/mutationlog.d.ts +1 -0
- package/dist/leader-thread/mutationlog.d.ts.map +1 -1
- package/dist/leader-thread/mutationlog.js +2 -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 +27 -10
- package/dist/schema/MutationEvent.d.ts.map +1 -1
- package/dist/schema/MutationEvent.js +24 -8
- 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/ClientSessionSyncProcessor.d.ts +2 -0
- package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.js +8 -5
- package/dist/sync/ClientSessionSyncProcessor.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 +19 -1
- package/dist/sync/sync.d.ts.map +1 -1
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/syncstate.d.ts +26 -4
- package/dist/sync/syncstate.d.ts.map +1 -1
- package/dist/sync/syncstate.js +95 -25
- package/dist/sync/syncstate.js.map +1 -1
- package/dist/sync/syncstate.test.js +60 -29
- 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 +4 -2
- package/src/leader-thread/LeaderSyncProcessor.ts +19 -13
- 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 +3 -2
- package/src/leader-thread/mutationlog.ts +2 -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 +28 -12
- 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/ClientSessionSyncProcessor.ts +10 -4
- package/src/sync/next/rebase-events.ts +1 -1
- package/src/sync/sync.ts +19 -3
- package/src/sync/syncstate.test.ts +66 -32
- package/src/sync/syncstate.ts +116 -34
- package/src/version.ts +1 -1
- 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
|
-
|
38
|
-
|
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': {
|
package/src/query-builder/api.ts
CHANGED
@@ -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 =
|
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
|
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
|
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
|
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
|
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
|
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 =
|
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<
|
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<
|
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
|