@effect-app/infra 4.0.0-beta.8 → 4.0.0-beta.82

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 (177) hide show
  1. package/CHANGELOG.md +540 -0
  2. package/dist/CUPS.d.ts +3 -3
  3. package/dist/CUPS.d.ts.map +1 -1
  4. package/dist/CUPS.js +3 -3
  5. package/dist/Emailer/Sendgrid.js +1 -1
  6. package/dist/Emailer/service.d.ts +3 -3
  7. package/dist/Emailer/service.d.ts.map +1 -1
  8. package/dist/Emailer/service.js +3 -3
  9. package/dist/MainFiberSet.d.ts +2 -2
  10. package/dist/MainFiberSet.d.ts.map +1 -1
  11. package/dist/MainFiberSet.js +3 -3
  12. package/dist/Model/Repository/internal/internal.d.ts +3 -3
  13. package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
  14. package/dist/Model/Repository/internal/internal.js +11 -7
  15. package/dist/Model/Repository/makeRepo.d.ts +2 -2
  16. package/dist/Model/Repository/makeRepo.d.ts.map +1 -1
  17. package/dist/Model/Repository/makeRepo.js +1 -1
  18. package/dist/Model/Repository/validation.d.ts +5 -4
  19. package/dist/Model/Repository/validation.d.ts.map +1 -1
  20. package/dist/Model/query/dsl.d.ts +9 -9
  21. package/dist/Operations.d.ts +2 -2
  22. package/dist/Operations.d.ts.map +1 -1
  23. package/dist/Operations.js +3 -3
  24. package/dist/OperationsRepo.d.ts +2 -2
  25. package/dist/OperationsRepo.d.ts.map +1 -1
  26. package/dist/OperationsRepo.js +3 -3
  27. package/dist/QueueMaker/SQLQueue.d.ts +3 -5
  28. package/dist/QueueMaker/SQLQueue.d.ts.map +1 -1
  29. package/dist/QueueMaker/SQLQueue.js +9 -7
  30. package/dist/QueueMaker/errors.d.ts +1 -1
  31. package/dist/QueueMaker/errors.d.ts.map +1 -1
  32. package/dist/QueueMaker/memQueue.d.ts.map +1 -1
  33. package/dist/QueueMaker/memQueue.js +10 -9
  34. package/dist/QueueMaker/sbqueue.d.ts.map +1 -1
  35. package/dist/QueueMaker/sbqueue.js +11 -9
  36. package/dist/RequestContext.d.ts +19 -14
  37. package/dist/RequestContext.d.ts.map +1 -1
  38. package/dist/RequestContext.js +5 -5
  39. package/dist/RequestFiberSet.d.ts +2 -2
  40. package/dist/RequestFiberSet.d.ts.map +1 -1
  41. package/dist/RequestFiberSet.js +5 -5
  42. package/dist/Store/ContextMapContainer.d.ts +14 -3
  43. package/dist/Store/ContextMapContainer.d.ts.map +1 -1
  44. package/dist/Store/ContextMapContainer.js +64 -3
  45. package/dist/Store/Cosmos.d.ts.map +1 -1
  46. package/dist/Store/Cosmos.js +55 -32
  47. package/dist/Store/Disk.d.ts.map +1 -1
  48. package/dist/Store/Disk.js +3 -4
  49. package/dist/Store/Memory.d.ts +2 -2
  50. package/dist/Store/Memory.d.ts.map +1 -1
  51. package/dist/Store/Memory.js +4 -4
  52. package/dist/Store/SQL/Pg.d.ts +4 -0
  53. package/dist/Store/SQL/Pg.d.ts.map +1 -0
  54. package/dist/Store/SQL/Pg.js +174 -0
  55. package/dist/Store/SQL/query.d.ts +34 -0
  56. package/dist/Store/SQL/query.d.ts.map +1 -0
  57. package/dist/Store/SQL/query.js +326 -0
  58. package/dist/Store/SQL.d.ts +4 -0
  59. package/dist/Store/SQL.d.ts.map +1 -0
  60. package/dist/Store/SQL.js +203 -0
  61. package/dist/Store/index.d.ts +1 -1
  62. package/dist/Store/index.d.ts.map +1 -1
  63. package/dist/Store/index.js +11 -1
  64. package/dist/Store/service.d.ts +8 -5
  65. package/dist/Store/service.d.ts.map +1 -1
  66. package/dist/Store/service.js +14 -6
  67. package/dist/adapters/SQL/Model.d.ts +2 -5
  68. package/dist/adapters/SQL/Model.d.ts.map +1 -1
  69. package/dist/adapters/SQL/Model.js +21 -13
  70. package/dist/adapters/ServiceBus.d.ts +6 -6
  71. package/dist/adapters/ServiceBus.d.ts.map +1 -1
  72. package/dist/adapters/ServiceBus.js +9 -9
  73. package/dist/adapters/cosmos-client.d.ts +2 -2
  74. package/dist/adapters/cosmos-client.d.ts.map +1 -1
  75. package/dist/adapters/cosmos-client.js +3 -3
  76. package/dist/adapters/logger.d.ts.map +1 -1
  77. package/dist/adapters/memQueue.d.ts +2 -2
  78. package/dist/adapters/memQueue.d.ts.map +1 -1
  79. package/dist/adapters/memQueue.js +3 -3
  80. package/dist/adapters/mongo-client.d.ts +2 -2
  81. package/dist/adapters/mongo-client.d.ts.map +1 -1
  82. package/dist/adapters/mongo-client.js +3 -3
  83. package/dist/adapters/redis-client.d.ts +3 -3
  84. package/dist/adapters/redis-client.d.ts.map +1 -1
  85. package/dist/adapters/redis-client.js +3 -3
  86. package/dist/api/ContextProvider.d.ts +6 -6
  87. package/dist/api/ContextProvider.d.ts.map +1 -1
  88. package/dist/api/ContextProvider.js +6 -6
  89. package/dist/api/internal/RequestContextMiddleware.d.ts +1 -1
  90. package/dist/api/internal/auth.d.ts +1 -1
  91. package/dist/api/internal/events.d.ts +2 -2
  92. package/dist/api/internal/events.d.ts.map +1 -1
  93. package/dist/api/internal/events.js +7 -5
  94. package/dist/api/layerUtils.d.ts +5 -5
  95. package/dist/api/layerUtils.d.ts.map +1 -1
  96. package/dist/api/layerUtils.js +5 -5
  97. package/dist/api/routing/middleware/RouterMiddleware.d.ts +3 -3
  98. package/dist/api/routing/middleware/RouterMiddleware.d.ts.map +1 -1
  99. package/dist/api/routing/middleware/middleware.d.ts +35 -1
  100. package/dist/api/routing/middleware/middleware.d.ts.map +1 -1
  101. package/dist/api/routing/middleware/middleware.js +39 -1
  102. package/dist/api/routing/schema/jwt.d.ts +1 -1
  103. package/dist/api/routing/schema/jwt.d.ts.map +1 -1
  104. package/dist/api/routing/schema/jwt.js +1 -1
  105. package/dist/api/routing.d.ts +1 -5
  106. package/dist/api/routing.d.ts.map +1 -1
  107. package/dist/api/routing.js +3 -2
  108. package/dist/api/setupRequest.d.ts +6 -3
  109. package/dist/api/setupRequest.d.ts.map +1 -1
  110. package/dist/api/setupRequest.js +11 -6
  111. package/dist/errorReporter.d.ts +1 -1
  112. package/dist/errorReporter.d.ts.map +1 -1
  113. package/dist/errorReporter.js +1 -1
  114. package/dist/fileUtil.js +1 -1
  115. package/dist/logger.d.ts.map +1 -1
  116. package/dist/rateLimit.js +1 -1
  117. package/examples/query.ts +29 -25
  118. package/package.json +32 -18
  119. package/src/CUPS.ts +2 -2
  120. package/src/Emailer/Sendgrid.ts +1 -1
  121. package/src/Emailer/service.ts +2 -2
  122. package/src/MainFiberSet.ts +2 -2
  123. package/src/Model/Repository/internal/internal.ts +11 -8
  124. package/src/Model/Repository/makeRepo.ts +2 -2
  125. package/src/Operations.ts +2 -2
  126. package/src/OperationsRepo.ts +2 -2
  127. package/src/QueueMaker/SQLQueue.ts +10 -10
  128. package/src/QueueMaker/memQueue.ts +41 -42
  129. package/src/QueueMaker/sbqueue.ts +65 -62
  130. package/src/RequestContext.ts +4 -4
  131. package/src/RequestFiberSet.ts +4 -4
  132. package/src/Store/ContextMapContainer.ts +98 -2
  133. package/src/Store/Cosmos.ts +207 -172
  134. package/src/Store/Disk.ts +2 -3
  135. package/src/Store/Memory.ts +4 -6
  136. package/src/Store/SQL/Pg.ts +294 -0
  137. package/src/Store/SQL/query.ts +372 -0
  138. package/src/Store/SQL.ts +327 -0
  139. package/src/Store/index.ts +10 -0
  140. package/src/Store/service.ts +16 -7
  141. package/src/adapters/SQL/Model.ts +76 -71
  142. package/src/adapters/ServiceBus.ts +8 -8
  143. package/src/adapters/cosmos-client.ts +2 -2
  144. package/src/adapters/memQueue.ts +2 -2
  145. package/src/adapters/mongo-client.ts +2 -2
  146. package/src/adapters/redis-client.ts +2 -2
  147. package/src/api/ContextProvider.ts +11 -11
  148. package/src/api/internal/events.ts +7 -6
  149. package/src/api/layerUtils.ts +8 -8
  150. package/src/api/routing/middleware/RouterMiddleware.ts +4 -4
  151. package/src/api/routing/middleware/middleware.ts +43 -0
  152. package/src/api/routing/schema/jwt.ts +2 -3
  153. package/src/api/routing.ts +7 -6
  154. package/src/api/setupRequest.ts +27 -7
  155. package/src/errorReporter.ts +1 -1
  156. package/src/fileUtil.ts +1 -1
  157. package/src/rateLimit.ts +2 -2
  158. package/test/contextProvider.test.ts +5 -5
  159. package/test/controller.test.ts +12 -9
  160. package/test/dist/contextProvider.test.d.ts.map +1 -1
  161. package/test/dist/controller.test.d.ts.map +1 -1
  162. package/test/dist/fixtures.d.ts +18 -8
  163. package/test/dist/fixtures.d.ts.map +1 -1
  164. package/test/dist/fixtures.js +11 -9
  165. package/test/dist/query.test.d.ts.map +1 -1
  166. package/test/dist/rawQuery.test.d.ts.map +1 -1
  167. package/test/dist/requires.test.d.ts.map +1 -1
  168. package/test/dist/rpc-multi-middleware.test.d.ts.map +1 -1
  169. package/test/dist/sql-store.test.d.ts.map +1 -0
  170. package/test/fixtures.ts +10 -8
  171. package/test/query.test.ts +160 -14
  172. package/test/rawQuery.test.ts +19 -17
  173. package/test/requires.test.ts +6 -5
  174. package/test/rpc-multi-middleware.test.ts +73 -4
  175. package/test/sql-store.test.ts +444 -0
  176. package/test/validateSample.test.ts +1 -1
  177. package/tsconfig.json +0 -1
@@ -0,0 +1,294 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import { Effect, type NonEmptyReadonlyArray, Option, Struct } from "effect-app"
4
+ import { toNonEmptyArray } from "effect-app/Array"
5
+ import { SqlClient } from "effect/unstable/sql"
6
+ import { OptimisticConcurrencyException } from "../../errors.js"
7
+ import { InfraLogger } from "../../logger.js"
8
+ import type { FieldValues } from "../../Model/filter/types.js"
9
+ import { storeId } from "../Memory.js"
10
+ import { type FilterArgs, type PersistenceModelType, type StorageConfig, type Store, type StoreConfig, StoreMaker } from "../service.js"
11
+ import { makeETag } from "../utils.js"
12
+ import { buildWhereSQLQuery, logQuery, pgDialect } from "./query.js"
13
+
14
+ const parseRow = <Encoded extends FieldValues>(
15
+ row: { id: string; _etag: string | null; data: unknown },
16
+ defaultValues: Partial<Encoded>
17
+ ): PersistenceModelType<Encoded> => {
18
+ const data = (typeof row.data === "string" ? JSON.parse(row.data) : row.data) as object
19
+ return { ...defaultValues, ...data, _etag: row._etag ?? undefined } as PersistenceModelType<Encoded>
20
+ }
21
+
22
+ const parseSelectRow = (
23
+ row: Record<string, unknown>,
24
+ idKey: PropertyKey,
25
+ defaultValues: Record<string, unknown>
26
+ ): any => {
27
+ const result: Record<string, unknown> = { ...defaultValues }
28
+ for (const [key, value] of Object.entries(row)) {
29
+ if (key === "id") {
30
+ result[idKey as string] = value
31
+ result["id"] = value
32
+ } else {
33
+ result[key] = value
34
+ }
35
+ }
36
+ return result
37
+ }
38
+
39
+ function makePgStore({ prefix }: StorageConfig) {
40
+ return Effect.gen(function*() {
41
+ const sql = yield* SqlClient.SqlClient
42
+ return {
43
+ make: <IdKey extends keyof Encoded, Encoded extends FieldValues, R = never, E = never>(
44
+ name: string,
45
+ idKey: IdKey,
46
+ seed?: Effect.Effect<Iterable<Encoded>, E, R>,
47
+ config?: StoreConfig<Encoded>
48
+ ) =>
49
+ Effect.gen(function*() {
50
+ type PM = PersistenceModelType<Encoded>
51
+ const tableName = `${prefix}${name}`
52
+ const defaultValues = config?.defaultValues ?? {}
53
+
54
+ const resolveNamespace = !config?.allowNamespace
55
+ ? Effect.succeed("primary")
56
+ : storeId.asEffect().pipe(Effect.map((namespace) => {
57
+ if (namespace !== "primary" && !config.allowNamespace!(namespace)) {
58
+ throw new Error(`Namespace ${namespace} not allowed!`)
59
+ }
60
+ return namespace
61
+ }))
62
+
63
+ yield* sql
64
+ .unsafe(
65
+ `CREATE TABLE IF NOT EXISTS "${tableName}" (id TEXT NOT NULL, _namespace TEXT NOT NULL DEFAULT 'primary', _etag TEXT, data JSONB NOT NULL, PRIMARY KEY (id, _namespace))`
66
+ )
67
+ .pipe(Effect.orDie)
68
+
69
+ const toRow = (e: PM) => {
70
+ const newE = makeETag(e)
71
+ const id = newE[idKey] as string
72
+ const data = JSON.stringify(newE)
73
+ return { id, _etag: newE._etag!, data, item: newE }
74
+ }
75
+
76
+ const exec = (query: string, params?: readonly unknown[]) =>
77
+ sql.unsafe(query, params as any).pipe(Effect.orDie)
78
+
79
+ const s: Store<IdKey, Encoded> = {
80
+ all: resolveNamespace.pipe(Effect.flatMap((ns) =>
81
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE _namespace = $1`, [ns])
82
+ .pipe(
83
+ Effect.map((rows) => (rows as any[]).map((r) => parseRow<Encoded>(r, defaultValues))),
84
+ Effect.withSpan("PgSQL.all [effect-app/infra/Store]", {
85
+ attributes: {
86
+ "repository.table_name": tableName,
87
+ "repository.model_name": name,
88
+ "repository.namespace": ns
89
+ }
90
+ }, { captureStackTrace: false })
91
+ )
92
+ )),
93
+
94
+ find: (id) =>
95
+ resolveNamespace.pipe(Effect
96
+ .flatMap((ns) =>
97
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE id = $1 AND _namespace = $2`, [id, ns])
98
+ .pipe(
99
+ Effect.map((rows) => {
100
+ const row = (rows as any[])[0]
101
+ return row
102
+ ? Option.some(parseRow<Encoded>(row, defaultValues))
103
+ : Option.none()
104
+ }),
105
+ Effect.withSpan("PgSQL.find [effect-app/infra/Store]", {
106
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id }
107
+ }, { captureStackTrace: false })
108
+ )
109
+ )),
110
+
111
+ filter: <U extends keyof Encoded = never>(f: FilterArgs<Encoded, U>) => {
112
+ const filter = f
113
+ .filter
114
+ type M = U extends undefined ? Encoded : Pick<Encoded, U>
115
+ return resolveNamespace.pipe(Effect.flatMap((ns) =>
116
+ Effect
117
+ .sync(() => {
118
+ const q = buildWhereSQLQuery(
119
+ pgDialect,
120
+ idKey,
121
+ filter ? [{ t: "where-scope", result: filter, relation: "some" }] : [],
122
+ tableName,
123
+ defaultValues,
124
+ f.select as
125
+ | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
126
+ | undefined,
127
+ f.order as NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }> | undefined,
128
+ f.skip,
129
+ f.limit
130
+ )
131
+ const nsPlaceholder = pgDialect.placeholder(q.params.length + 1)
132
+ const hasWhere = q.sql.includes("WHERE")
133
+ const nsSql = hasWhere
134
+ ? q.sql.replace("WHERE", `WHERE _namespace = ${nsPlaceholder} AND`)
135
+ : q.sql.replace(
136
+ `FROM "${tableName}"`,
137
+ `FROM "${tableName}" WHERE _namespace = ${nsPlaceholder}`
138
+ )
139
+ return { sql: nsSql, params: [...q.params, ns] }
140
+ })
141
+ .pipe(
142
+ Effect.tap((q) => logQuery(q)),
143
+ Effect.flatMap((q) =>
144
+ exec(q.sql, q.params).pipe(
145
+ Effect.map((rows) => {
146
+ if (f.select) {
147
+ return (rows as any[]).map((r) => {
148
+ const selected = parseSelectRow(r, idKey, {})
149
+ return {
150
+ ...Struct.pick(
151
+ defaultValues as any,
152
+ f.select!.filter((_) => typeof _ === "string") as never[]
153
+ ),
154
+ ...selected
155
+ } as M
156
+ })
157
+ }
158
+ return (rows as any[]).map((r) => parseRow<Encoded>(r, defaultValues) as any as M)
159
+ })
160
+ )
161
+ ),
162
+ Effect.withSpan("PgSQL.filter [effect-app/infra/Store]", {
163
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
164
+ }, { captureStackTrace: false })
165
+ )
166
+ ))
167
+ },
168
+
169
+ set: (e) =>
170
+ resolveNamespace.pipe(Effect.flatMap((ns) =>
171
+ Effect
172
+ .gen(function*() {
173
+ const row = toRow(e)
174
+ if (e._etag) {
175
+ yield* exec(
176
+ `UPDATE "${tableName}" SET _etag = $1, data = $2 WHERE id = $3 AND _etag = $4 AND _namespace = $5`,
177
+ [row._etag, row.data, row.id, e._etag, ns]
178
+ )
179
+ const existing = yield* exec(
180
+ `SELECT _etag FROM "${tableName}" WHERE id = $1 AND _namespace = $2`,
181
+ [row.id, ns]
182
+ )
183
+ const current = (existing as any[])[0]
184
+ if (!current || current._etag !== row._etag) {
185
+ if (current) {
186
+ return yield* new OptimisticConcurrencyException({
187
+ type: name,
188
+ id: row.id,
189
+ current: current._etag,
190
+ found: e._etag,
191
+ code: 412
192
+ })
193
+ }
194
+ return yield* new OptimisticConcurrencyException({
195
+ type: name,
196
+ id: row.id,
197
+ current: "",
198
+ found: e._etag,
199
+ code: 404
200
+ })
201
+ }
202
+ } else {
203
+ yield* exec(
204
+ `INSERT INTO "${tableName}" (id, _namespace, _etag, data) VALUES ($1, $2, $3, $4)`,
205
+ [row.id, ns, row._etag, row.data]
206
+ )
207
+ }
208
+ return row.item
209
+ })
210
+ .pipe(
211
+ Effect.withSpan("PgSQL.set [effect-app/infra/Store]", {
212
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id: e[idKey] }
213
+ }, { captureStackTrace: false })
214
+ )
215
+ )),
216
+
217
+ batchSet: (items) =>
218
+ sql
219
+ .withTransaction(
220
+ Effect.forEach(items, (e) => s.set(e))
221
+ )
222
+ .pipe(
223
+ Effect.orDie,
224
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>),
225
+ Effect.withSpan("PgSQL.batchSet [effect-app/infra/Store]", {
226
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
227
+ }, { captureStackTrace: false })
228
+ ),
229
+
230
+ bulkSet: (items) =>
231
+ sql
232
+ .withTransaction(
233
+ Effect.forEach(items, (e) => s.set(e))
234
+ )
235
+ .pipe(
236
+ Effect.orDie,
237
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>),
238
+ Effect.withSpan("PgSQL.bulkSet [effect-app/infra/Store]", {
239
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
240
+ }, { captureStackTrace: false })
241
+ ),
242
+
243
+ batchRemove: (ids) => {
244
+ const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ")
245
+ const nsPlaceholder = `$${ids.length + 1}`
246
+ return resolveNamespace.pipe(Effect.flatMap((ns) =>
247
+ exec(
248
+ `DELETE FROM "${tableName}" WHERE id IN (${placeholders}) AND _namespace = ${nsPlaceholder}`,
249
+ [...ids, ns]
250
+ )
251
+ .pipe(
252
+ Effect.asVoid,
253
+ Effect.withSpan("PgSQL.batchRemove [effect-app/infra/Store]", {
254
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
255
+ }, { captureStackTrace: false })
256
+ )
257
+ ))
258
+ },
259
+
260
+ queryRaw: (query) =>
261
+ s.all.pipe(
262
+ Effect.map(query.memory),
263
+ Effect.withSpan("PgSQL.queryRaw [effect-app/infra/Store]", {
264
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
265
+ }, { captureStackTrace: false })
266
+ )
267
+ }
268
+
269
+ if (seed) {
270
+ const existing = yield* exec(
271
+ `SELECT COUNT(*) as cnt FROM "${tableName}" WHERE _namespace = $1`,
272
+ ["primary"]
273
+ )
274
+ const count = Number((existing as any[])[0]?.cnt ?? 0)
275
+ if (count === 0) {
276
+ yield* InfraLogger.logInfo("Seeding data for " + name)
277
+ const items = yield* seed
278
+ yield* Effect.flatMapOption(
279
+ Effect.succeed(toNonEmptyArray([...items])),
280
+ (a) => s.bulkSet(a).pipe(Effect.orDie)
281
+ )
282
+ }
283
+ }
284
+
285
+ return s
286
+ })
287
+ }
288
+ })
289
+ }
290
+
291
+ export function PgStoreLayer(cfg: StorageConfig) {
292
+ return StoreMaker
293
+ .toLayer(makePgStore(cfg))
294
+ }
@@ -0,0 +1,372 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Effect, type NonEmptyReadonlyArray } from "effect-app"
3
+ import { assertUnreachable } from "effect-app/utils"
4
+ import { InfraLogger } from "../../logger.js"
5
+ import type { FilterR, FilterResult, Ops } from "../../Model/filter/filterApi.js"
6
+ import { isRelationCheck } from "../codeFilter.js"
7
+
8
+ export interface SQLDialect {
9
+ readonly jsonExtract: (path: string) => string
10
+ readonly jsonExtractJson: (path: string) => string
11
+ readonly placeholder: (index: number) => string
12
+ readonly jsonArrayContains: (arrPath: string, valPlaceholder: string) => string
13
+ readonly jsonArrayNotContains: (arrPath: string, valPlaceholder: string) => string
14
+ readonly jsonArrayContainsAny: (arrPath: string, valPlaceholders: readonly string[]) => string
15
+ readonly jsonArrayNotContainsAny: (arrPath: string, valPlaceholders: readonly string[]) => string
16
+ readonly jsonArrayContainsAll: (arrPath: string, valPlaceholders: readonly string[]) => string
17
+ readonly jsonArrayNotContainsAll: (arrPath: string, valPlaceholders: readonly string[]) => string
18
+ readonly caseInsensitiveLike: (expr: string, valPlaceholder: string) => string
19
+ readonly caseInsensitiveNotLike: (expr: string, valPlaceholder: string) => string
20
+ readonly jsonColumnType: "JSON" | "JSONB"
21
+ readonly arrayLength: (path: string) => string
22
+ }
23
+
24
+ export const sqliteDialect: SQLDialect = {
25
+ jsonExtract: (path) => `json_extract(data, '$.${path}')`,
26
+ jsonExtractJson: (path) => `json_extract(data, '$.${path}')`,
27
+ placeholder: (_index) => "?",
28
+ jsonArrayContains: (arrPath, val) => `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value = ${val})`,
29
+ jsonArrayNotContains: (arrPath, val) =>
30
+ `NOT EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value = ${val})`,
31
+ jsonArrayContainsAny: (arrPath, vals) =>
32
+ `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value IN (${vals.join(", ")}))`,
33
+ jsonArrayNotContainsAny: (arrPath, vals) =>
34
+ `NOT EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value IN (${vals.join(", ")}))`,
35
+ jsonArrayContainsAll: (arrPath, vals) =>
36
+ vals.map((v) => `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value = ${v})`).join(" AND "),
37
+ jsonArrayNotContainsAll: (arrPath, vals) =>
38
+ `NOT (${
39
+ vals.map((v) => `EXISTS(SELECT 1 FROM json_each(data, '$.${arrPath}') WHERE value = ${v})`).join(" AND ")
40
+ })`,
41
+ caseInsensitiveLike: (expr, val) => `LOWER(${expr}) LIKE LOWER(${val})`,
42
+ caseInsensitiveNotLike: (expr, val) => `LOWER(${expr}) NOT LIKE LOWER(${val})`,
43
+ jsonColumnType: "JSON",
44
+ arrayLength: (path) => `json_array_length(data, '$.${path}')`
45
+ }
46
+
47
+ export const pgDialect: SQLDialect = {
48
+ jsonExtract: (path) => {
49
+ const parts = path.split(".")
50
+ if (parts.length === 1) return `data->>'${parts[0]}'`
51
+ const last = parts.pop()!
52
+ return `data${parts.map((p) => `->'${p}'`).join("")}->>'${last}'`
53
+ },
54
+ jsonExtractJson: (path) => {
55
+ const parts = path.split(".")
56
+ if (parts.length === 1) return `data->'${parts[0]}'`
57
+ return `data${parts.map((p) => `->'${p}'`).join("")}`
58
+ },
59
+ placeholder: (index) => `$${index}`,
60
+ jsonArrayContains: (arrPath, val) => {
61
+ const parts = arrPath.split(".")
62
+ const jsonPath = parts.length === 1
63
+ ? `data->'${parts[0]}'`
64
+ : `data${parts.map((p) => `->'${p}'`).join("")}`
65
+ return `${jsonPath} @> ${val}::jsonb`
66
+ },
67
+ jsonArrayNotContains: (arrPath, val) => {
68
+ const parts = arrPath.split(".")
69
+ const jsonPath = parts.length === 1
70
+ ? `data->'${parts[0]}'`
71
+ : `data${parts.map((p) => `->'${p}'`).join("")}`
72
+ return `NOT (${jsonPath} @> ${val}::jsonb)`
73
+ },
74
+ jsonArrayContainsAny: (arrPath, vals) => {
75
+ const parts = arrPath.split(".")
76
+ const jsonPath = parts.length === 1
77
+ ? `data->'${parts[0]}'`
78
+ : `data${parts.map((p) => `->'${p}'`).join("")}`
79
+ return `(${vals.map((v) => `${jsonPath} @> ${v}::jsonb`).join(" OR ")})`
80
+ },
81
+ jsonArrayNotContainsAny: (arrPath, vals) => {
82
+ const parts = arrPath.split(".")
83
+ const jsonPath = parts.length === 1
84
+ ? `data->'${parts[0]}'`
85
+ : `data${parts.map((p) => `->'${p}'`).join("")}`
86
+ return `NOT (${vals.map((v) => `${jsonPath} @> ${v}::jsonb`).join(" OR ")})`
87
+ },
88
+ jsonArrayContainsAll: (arrPath, vals) => {
89
+ const parts = arrPath.split(".")
90
+ const jsonPath = parts.length === 1
91
+ ? `data->'${parts[0]}'`
92
+ : `data${parts.map((p) => `->'${p}'`).join("")}`
93
+ return vals.map((v) => `${jsonPath} @> ${v}::jsonb`).join(" AND ")
94
+ },
95
+ jsonArrayNotContainsAll: (arrPath, vals) => {
96
+ const parts = arrPath.split(".")
97
+ const jsonPath = parts.length === 1
98
+ ? `data->'${parts[0]}'`
99
+ : `data${parts.map((p) => `->'${p}'`).join("")}`
100
+ return `NOT (${vals.map((v) => `${jsonPath} @> ${v}::jsonb`).join(" AND ")})`
101
+ },
102
+ caseInsensitiveLike: (expr, val) => `${expr} ILIKE ${val}`,
103
+ caseInsensitiveNotLike: (expr, val) => `${expr} NOT ILIKE ${val}`,
104
+ jsonColumnType: "JSONB",
105
+ arrayLength: (path) => `jsonb_array_length(data->'${path}')`
106
+ }
107
+
108
+ export function logQuery(q: { sql: string; params: unknown[] }) {
109
+ return InfraLogger
110
+ .logDebug("sql query")
111
+ .pipe(Effect.annotateLogs({
112
+ query: q.sql,
113
+ parameters: JSON.stringify(q.params, undefined, 2)
114
+ }))
115
+ }
116
+
117
+ const dottedToJsonPath = (path: string) =>
118
+ path
119
+ .split(".")
120
+ .filter((p) => p !== "-1")
121
+ .join(".")
122
+
123
+ export function buildWhereSQLQuery(
124
+ dialect: SQLDialect,
125
+ idKey: PropertyKey,
126
+ filter: readonly FilterResult[],
127
+ tableName: string,
128
+ _defaultValues: Record<string, unknown>,
129
+ select?: NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>,
130
+ order?: NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }>,
131
+ skip?: number,
132
+ limit?: number
133
+ ) {
134
+ const params: unknown[] = []
135
+ let paramIndex = 1
136
+
137
+ const addParam = (value: unknown): string => {
138
+ params.push(value)
139
+ return dialect.placeholder(paramIndex++)
140
+ }
141
+
142
+ const fieldExpr = (path: string): string => {
143
+ if (path === idKey || path === "id") return "id"
144
+ if (path.endsWith(".length")) {
145
+ const arrPath = dottedToJsonPath(path.slice(0, -".length".length))
146
+ return dialect.arrayLength(arrPath)
147
+ }
148
+ const jsonPath = dottedToJsonPath(path)
149
+ return dialect.jsonExtract(jsonPath)
150
+ }
151
+
152
+ const statement = (x: FilterR): string => {
153
+ const resolvedPath = x.path === idKey ? "id" : x.path
154
+ const k = fieldExpr(resolvedPath)
155
+
156
+ switch (x.op) {
157
+ case "in": {
158
+ const vals = x.value as unknown as readonly unknown[]
159
+ const placeholders = vals.map((v) => addParam(v))
160
+ return `${k} IN (${placeholders.join(", ")})`
161
+ }
162
+ case "notIn": {
163
+ const vals = x.value as unknown as readonly unknown[]
164
+ const placeholders = vals.map((v) => addParam(v))
165
+ return `${k} NOT IN (${placeholders.join(", ")})`
166
+ }
167
+
168
+ case "includes": {
169
+ const arrPath = dottedToJsonPath(resolvedPath)
170
+ const v = addParam(x.value)
171
+ return dialect.jsonArrayContains(arrPath, v)
172
+ }
173
+ case "notIncludes": {
174
+ const arrPath = dottedToJsonPath(resolvedPath)
175
+ const v = addParam(x.value)
176
+ return dialect.jsonArrayNotContains(arrPath, v)
177
+ }
178
+
179
+ case "includes-any": {
180
+ const arrPath = dottedToJsonPath(resolvedPath)
181
+ const vals = x.value as unknown as readonly unknown[]
182
+ const placeholders = vals.map((v) => addParam(JSON.stringify(v)))
183
+ return dialect.jsonArrayContainsAny(arrPath, placeholders)
184
+ }
185
+ case "notIncludes-any": {
186
+ const arrPath = dottedToJsonPath(resolvedPath)
187
+ const vals = x.value as unknown as readonly unknown[]
188
+ const placeholders = vals.map((v) => addParam(JSON.stringify(v)))
189
+ return dialect.jsonArrayNotContainsAny(arrPath, placeholders)
190
+ }
191
+
192
+ case "includes-all": {
193
+ const arrPath = dottedToJsonPath(resolvedPath)
194
+ const vals = x.value as unknown as readonly unknown[]
195
+ const placeholders = vals.map((v) => addParam(JSON.stringify(v)))
196
+ return dialect.jsonArrayContainsAll(arrPath, placeholders)
197
+ }
198
+ case "notIncludes-all": {
199
+ const arrPath = dottedToJsonPath(resolvedPath)
200
+ const vals = x.value as unknown as readonly unknown[]
201
+ const placeholders = vals.map((v) => addParam(JSON.stringify(v)))
202
+ return dialect.jsonArrayNotContainsAll(arrPath, placeholders)
203
+ }
204
+
205
+ case "contains": {
206
+ const v = addParam(`%${x.value}%`)
207
+ return dialect.caseInsensitiveLike(k, v)
208
+ }
209
+ case "notContains": {
210
+ const v = addParam(`%${x.value}%`)
211
+ return dialect.caseInsensitiveNotLike(k, v)
212
+ }
213
+ case "startsWith": {
214
+ const v = addParam(`${x.value}%`)
215
+ return dialect.caseInsensitiveLike(k, v)
216
+ }
217
+ case "notStartsWith": {
218
+ const v = addParam(`${x.value}%`)
219
+ return dialect.caseInsensitiveNotLike(k, v)
220
+ }
221
+ case "endsWith": {
222
+ const v = addParam(`%${x.value}`)
223
+ return dialect.caseInsensitiveLike(k, v)
224
+ }
225
+ case "notEndsWith": {
226
+ const v = addParam(`%${x.value}`)
227
+ return dialect.caseInsensitiveNotLike(k, v)
228
+ }
229
+
230
+ case "lt": {
231
+ const v = addParam(x.value)
232
+ return `${k} < ${v}`
233
+ }
234
+ case "lte": {
235
+ const v = addParam(x.value)
236
+ return `${k} <= ${v}`
237
+ }
238
+ case "gt": {
239
+ const v = addParam(x.value)
240
+ return `${k} > ${v}`
241
+ }
242
+ case "gte": {
243
+ const v = addParam(x.value)
244
+ return `${k} >= ${v}`
245
+ }
246
+ case "neq": {
247
+ if (x.value === null) return `${k} IS NOT NULL`
248
+ const v = addParam(x.value)
249
+ return `${k} <> ${v}`
250
+ }
251
+ case undefined:
252
+ case "eq": {
253
+ if (x.value === null) return `${k} IS NULL`
254
+ const v = addParam(x.value)
255
+ return `${k} = ${v}`
256
+ }
257
+ default:
258
+ return assertUnreachable(x.op)
259
+ }
260
+ }
261
+
262
+ const flipOps = {
263
+ gt: "lt",
264
+ lt: "gt",
265
+ gte: "lte",
266
+ lte: "gte",
267
+ contains: "notContains",
268
+ notContains: "contains",
269
+ startsWith: "notStartsWith",
270
+ notStartsWith: "startsWith",
271
+ endsWith: "notEndsWith",
272
+ notEndsWith: "endsWith",
273
+ eq: "neq",
274
+ neq: "eq",
275
+ includes: "notIncludes",
276
+ notIncludes: "includes",
277
+ "includes-any": "notIncludes-any",
278
+ "notIncludes-any": "includes-any",
279
+ "includes-all": "notIncludes-all",
280
+ "notIncludes-all": "includes-all",
281
+ in: "notIn",
282
+ notIn: "in"
283
+ } satisfies Record<Ops, Ops>
284
+
285
+ const flippies = {
286
+ and: "or",
287
+ or: "and"
288
+ } satisfies Record<"and" | "or", "and" | "or">
289
+
290
+ const flip = (every: boolean) => (_: FilterResult): FilterResult =>
291
+ every
292
+ ? _.t === "where" || _.t === "or" || _.t === "and"
293
+ ? { ..._, t: _.t === "where" ? _.t : flippies[_.t], op: flipOps[_.op] }
294
+ : _
295
+ : _
296
+
297
+ const print = (state: readonly FilterResult[], isRelation: string | null, every: boolean): string => {
298
+ let s = ""
299
+ for (const e of state) {
300
+ switch (e.t) {
301
+ case "where":
302
+ s += statement(e)
303
+ break
304
+ case "or":
305
+ s += ` OR ${statement(e)}`
306
+ break
307
+ case "and":
308
+ s += ` AND ${statement(e)}`
309
+ break
310
+ case "or-scope": {
311
+ if (!every) every = e.relation === "every"
312
+ const rel = isRelationCheck(e.result, isRelation)
313
+ if (rel) {
314
+ s += ` OR (${print(e.result.map(flip(every)), rel, every)})`
315
+ } else {
316
+ s += ` OR (${print(e.result, null, every)})`
317
+ }
318
+ break
319
+ }
320
+ case "and-scope": {
321
+ if (!every) every = e.relation === "every"
322
+ const rel = isRelationCheck(e.result, isRelation)
323
+ if (rel) {
324
+ s += ` AND (${print(e.result.map(flip(every)), rel, every)})`
325
+ } else {
326
+ s += ` AND (${print(e.result, null, every)})`
327
+ }
328
+ break
329
+ }
330
+ case "where-scope": {
331
+ if (!every) every = e.relation === "every"
332
+ const rel = isRelationCheck(e.result, isRelation)
333
+ if (rel) {
334
+ s += `(${print(e.result.map(flip(every)), rel, every)})`
335
+ } else {
336
+ s += `(${print(e.result, null, every)})`
337
+ }
338
+ break
339
+ }
340
+ }
341
+ }
342
+ return s
343
+ }
344
+
345
+ const getSelectExpr = (): string => {
346
+ if (!select) return "id, _etag, data"
347
+ const fields = select.map((s) => {
348
+ if (typeof s === "string") {
349
+ if (s === idKey || s === "id") return `id`
350
+ return `${dialect.jsonExtract(s)} AS "${s}"`
351
+ }
352
+ return `${dialect.jsonExtractJson(s.key)} AS "${s.key}"`
353
+ })
354
+ return fields.join(", ")
355
+ }
356
+
357
+ const whereClause = filter.length
358
+ ? `WHERE ${print([{ t: "where-scope", result: filter, relation: "some" }], null, false)}`
359
+ : ""
360
+
361
+ const orderClause = order
362
+ ? `ORDER BY ${order.map((_) => `${fieldExpr(_.key)} ${_.direction}`).join(", ")}`
363
+ : ""
364
+
365
+ const limitClause = limit !== undefined || skip !== undefined
366
+ ? `LIMIT ${addParam(limit ?? 999999)} OFFSET ${addParam(skip ?? 0)}`
367
+ : ""
368
+
369
+ const sql = `SELECT ${getSelectExpr()} FROM "${tableName}" ${whereClause} ${orderClause} ${limitClause}`.trim()
370
+
371
+ return { sql, params }
372
+ }