@effect-app/infra 4.0.0-beta.81 → 4.0.0-beta.83

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.
@@ -0,0 +1,296 @@
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
+ idKey: PropertyKey,
17
+ defaultValues: Partial<Encoded>
18
+ ): PersistenceModelType<Encoded> => {
19
+ const data = (typeof row.data === "string" ? JSON.parse(row.data) : row.data) as object
20
+ return { ...defaultValues, ...data, [idKey]: row.id, _etag: row._etag ?? undefined } as PersistenceModelType<Encoded>
21
+ }
22
+
23
+ const parseSelectRow = (
24
+ row: Record<string, unknown>,
25
+ idKey: PropertyKey,
26
+ defaultValues: Record<string, unknown>
27
+ ): any => {
28
+ const result: Record<string, unknown> = { ...defaultValues }
29
+ for (const [key, value] of Object.entries(row)) {
30
+ if (key === "id") {
31
+ result[idKey as string] = value
32
+ result["id"] = value
33
+ } else {
34
+ result[key] = value
35
+ }
36
+ }
37
+ return result
38
+ }
39
+
40
+ function makePgStore({ prefix }: StorageConfig) {
41
+ return Effect.gen(function*() {
42
+ const sql = yield* SqlClient.SqlClient
43
+ return {
44
+ make: <IdKey extends keyof Encoded, Encoded extends FieldValues, R = never, E = never>(
45
+ name: string,
46
+ idKey: IdKey,
47
+ seed?: Effect.Effect<Iterable<Encoded>, E, R>,
48
+ config?: StoreConfig<Encoded>
49
+ ) =>
50
+ Effect.gen(function*() {
51
+ type PM = PersistenceModelType<Encoded>
52
+ const tableName = `${prefix}${name}`
53
+ const defaultValues = config?.defaultValues ?? {}
54
+
55
+ const resolveNamespace = !config?.allowNamespace
56
+ ? Effect.succeed("primary")
57
+ : storeId.asEffect().pipe(Effect.map((namespace) => {
58
+ if (namespace !== "primary" && !config.allowNamespace!(namespace)) {
59
+ throw new Error(`Namespace ${namespace} not allowed!`)
60
+ }
61
+ return namespace
62
+ }))
63
+
64
+ yield* sql
65
+ .unsafe(
66
+ `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))`
67
+ )
68
+ .pipe(Effect.orDie)
69
+
70
+ const toRow = (e: PM) => {
71
+ const newE = makeETag(e)
72
+ const id = newE[idKey] as string
73
+ const { _etag, [idKey]: _id, ...rest } = newE as any
74
+ const data = JSON.stringify(rest)
75
+ return { id, _etag: newE._etag!, data, item: newE }
76
+ }
77
+
78
+ const exec = (query: string, params?: readonly unknown[]) =>
79
+ sql.unsafe(query, params as any).pipe(Effect.orDie)
80
+
81
+ const s: Store<IdKey, Encoded> = {
82
+ all: resolveNamespace.pipe(Effect.flatMap((ns) =>
83
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE _namespace = $1`, [ns])
84
+ .pipe(
85
+ Effect.map((rows) => (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues))),
86
+ Effect.withSpan("PgSQL.all [effect-app/infra/Store]", {
87
+ attributes: {
88
+ "repository.table_name": tableName,
89
+ "repository.model_name": name,
90
+ "repository.namespace": ns
91
+ }
92
+ }, { captureStackTrace: false })
93
+ )
94
+ )),
95
+
96
+ find: (id) =>
97
+ resolveNamespace.pipe(Effect
98
+ .flatMap((ns) =>
99
+ exec(`SELECT id, _etag, data FROM "${tableName}" WHERE id = $1 AND _namespace = $2`, [id, ns])
100
+ .pipe(
101
+ Effect.map((rows) => {
102
+ const row = (rows as any[])[0]
103
+ return row
104
+ ? Option.some(parseRow<Encoded>(row, idKey, defaultValues))
105
+ : Option.none()
106
+ }),
107
+ Effect.withSpan("PgSQL.find [effect-app/infra/Store]", {
108
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id }
109
+ }, { captureStackTrace: false })
110
+ )
111
+ )),
112
+
113
+ filter: <U extends keyof Encoded = never>(f: FilterArgs<Encoded, U>) => {
114
+ const filter = f
115
+ .filter
116
+ type M = U extends undefined ? Encoded : Pick<Encoded, U>
117
+ return resolveNamespace.pipe(Effect.flatMap((ns) =>
118
+ Effect
119
+ .sync(() => {
120
+ const q = buildWhereSQLQuery(
121
+ pgDialect,
122
+ idKey,
123
+ filter ? [{ t: "where-scope", result: filter, relation: "some" }] : [],
124
+ tableName,
125
+ defaultValues,
126
+ f.select as
127
+ | NonEmptyReadonlyArray<string | { key: string; subKeys: readonly string[] }>
128
+ | undefined,
129
+ f.order as NonEmptyReadonlyArray<{ key: string; direction: "ASC" | "DESC" }> | undefined,
130
+ f.skip,
131
+ f.limit
132
+ )
133
+ const nsPlaceholder = pgDialect.placeholder(q.params.length + 1)
134
+ const hasWhere = q.sql.includes("WHERE")
135
+ const nsSql = hasWhere
136
+ ? q.sql.replace("WHERE", `WHERE _namespace = ${nsPlaceholder} AND`)
137
+ : q.sql.replace(
138
+ `FROM "${tableName}"`,
139
+ `FROM "${tableName}" WHERE _namespace = ${nsPlaceholder}`
140
+ )
141
+ return { sql: nsSql, params: [...q.params, ns] }
142
+ })
143
+ .pipe(
144
+ Effect.tap((q) => logQuery(q)),
145
+ Effect.flatMap((q) =>
146
+ exec(q.sql, q.params).pipe(
147
+ Effect.map((rows) => {
148
+ if (f.select) {
149
+ return (rows as any[]).map((r) => {
150
+ const selected = parseSelectRow(r, idKey, {})
151
+ return {
152
+ ...Struct.pick(
153
+ defaultValues as any,
154
+ f.select!.filter((_) => typeof _ === "string") as never[]
155
+ ),
156
+ ...selected
157
+ } as M
158
+ })
159
+ }
160
+ return (rows as any[]).map((r) => parseRow<Encoded>(r, idKey, defaultValues) as any as M)
161
+ })
162
+ )
163
+ ),
164
+ Effect.withSpan("PgSQL.filter [effect-app/infra/Store]", {
165
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
166
+ }, { captureStackTrace: false })
167
+ )
168
+ ))
169
+ },
170
+
171
+ set: (e) =>
172
+ resolveNamespace.pipe(Effect.flatMap((ns) =>
173
+ Effect
174
+ .gen(function*() {
175
+ const row = toRow(e)
176
+ if (e._etag) {
177
+ yield* exec(
178
+ `UPDATE "${tableName}" SET _etag = $1, data = $2 WHERE id = $3 AND _etag = $4 AND _namespace = $5`,
179
+ [row._etag, row.data, row.id, e._etag, ns]
180
+ )
181
+ const existing = yield* exec(
182
+ `SELECT _etag FROM "${tableName}" WHERE id = $1 AND _namespace = $2`,
183
+ [row.id, ns]
184
+ )
185
+ const current = (existing as any[])[0]
186
+ if (!current || current._etag !== row._etag) {
187
+ if (current) {
188
+ return yield* new OptimisticConcurrencyException({
189
+ type: name,
190
+ id: row.id,
191
+ current: current._etag,
192
+ found: e._etag,
193
+ code: 412
194
+ })
195
+ }
196
+ return yield* new OptimisticConcurrencyException({
197
+ type: name,
198
+ id: row.id,
199
+ current: "",
200
+ found: e._etag,
201
+ code: 404
202
+ })
203
+ }
204
+ } else {
205
+ yield* exec(
206
+ `INSERT INTO "${tableName}" (id, _namespace, _etag, data) VALUES ($1, $2, $3, $4)`,
207
+ [row.id, ns, row._etag, row.data]
208
+ )
209
+ }
210
+ return row.item
211
+ })
212
+ .pipe(
213
+ Effect.withSpan("PgSQL.set [effect-app/infra/Store]", {
214
+ attributes: { "repository.table_name": tableName, "repository.model_name": name, id: e[idKey] }
215
+ }, { captureStackTrace: false })
216
+ )
217
+ )),
218
+
219
+ batchSet: (items) =>
220
+ sql
221
+ .withTransaction(
222
+ Effect.forEach(items, (e) => s.set(e))
223
+ )
224
+ .pipe(
225
+ Effect.orDie,
226
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>),
227
+ Effect.withSpan("PgSQL.batchSet [effect-app/infra/Store]", {
228
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
229
+ }, { captureStackTrace: false })
230
+ ),
231
+
232
+ bulkSet: (items) =>
233
+ sql
234
+ .withTransaction(
235
+ Effect.forEach(items, (e) => s.set(e))
236
+ )
237
+ .pipe(
238
+ Effect.orDie,
239
+ Effect.map((_) => _ as unknown as NonEmptyReadonlyArray<PM>),
240
+ Effect.withSpan("PgSQL.bulkSet [effect-app/infra/Store]", {
241
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
242
+ }, { captureStackTrace: false })
243
+ ),
244
+
245
+ batchRemove: (ids) => {
246
+ const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ")
247
+ const nsPlaceholder = `$${ids.length + 1}`
248
+ return resolveNamespace.pipe(Effect.flatMap((ns) =>
249
+ exec(
250
+ `DELETE FROM "${tableName}" WHERE id IN (${placeholders}) AND _namespace = ${nsPlaceholder}`,
251
+ [...ids, ns]
252
+ )
253
+ .pipe(
254
+ Effect.asVoid,
255
+ Effect.withSpan("PgSQL.batchRemove [effect-app/infra/Store]", {
256
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
257
+ }, { captureStackTrace: false })
258
+ )
259
+ ))
260
+ },
261
+
262
+ queryRaw: (query) =>
263
+ s.all.pipe(
264
+ Effect.map(query.memory),
265
+ Effect.withSpan("PgSQL.queryRaw [effect-app/infra/Store]", {
266
+ attributes: { "repository.table_name": tableName, "repository.model_name": name }
267
+ }, { captureStackTrace: false })
268
+ )
269
+ }
270
+
271
+ if (seed) {
272
+ const existing = yield* exec(
273
+ `SELECT COUNT(*) as cnt FROM "${tableName}" WHERE _namespace = $1`,
274
+ ["primary"]
275
+ )
276
+ const count = Number((existing as any[])[0]?.cnt ?? 0)
277
+ if (count === 0) {
278
+ yield* InfraLogger.logInfo("Seeding data for " + name)
279
+ const items = yield* seed
280
+ yield* Effect.flatMapOption(
281
+ Effect.succeed(toNonEmptyArray([...items])),
282
+ (a) => s.bulkSet(a).pipe(Effect.orDie)
283
+ )
284
+ }
285
+ }
286
+
287
+ return s
288
+ })
289
+ }
290
+ })
291
+ }
292
+
293
+ export function PgStoreLayer(cfg: StorageConfig) {
294
+ return StoreMaker
295
+ .toLayer(makePgStore(cfg))
296
+ }
@@ -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
+ }