@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.
- package/CHANGELOG.md +540 -0
- package/dist/CUPS.d.ts +3 -3
- package/dist/CUPS.d.ts.map +1 -1
- package/dist/CUPS.js +3 -3
- package/dist/Emailer/Sendgrid.js +1 -1
- package/dist/Emailer/service.d.ts +3 -3
- package/dist/Emailer/service.d.ts.map +1 -1
- package/dist/Emailer/service.js +3 -3
- package/dist/MainFiberSet.d.ts +2 -2
- package/dist/MainFiberSet.d.ts.map +1 -1
- package/dist/MainFiberSet.js +3 -3
- package/dist/Model/Repository/internal/internal.d.ts +3 -3
- package/dist/Model/Repository/internal/internal.d.ts.map +1 -1
- package/dist/Model/Repository/internal/internal.js +11 -7
- package/dist/Model/Repository/makeRepo.d.ts +2 -2
- package/dist/Model/Repository/makeRepo.d.ts.map +1 -1
- package/dist/Model/Repository/makeRepo.js +1 -1
- package/dist/Model/Repository/validation.d.ts +5 -4
- package/dist/Model/Repository/validation.d.ts.map +1 -1
- package/dist/Model/query/dsl.d.ts +9 -9
- package/dist/Operations.d.ts +2 -2
- package/dist/Operations.d.ts.map +1 -1
- package/dist/Operations.js +3 -3
- package/dist/OperationsRepo.d.ts +2 -2
- package/dist/OperationsRepo.d.ts.map +1 -1
- package/dist/OperationsRepo.js +3 -3
- package/dist/QueueMaker/SQLQueue.d.ts +3 -5
- package/dist/QueueMaker/SQLQueue.d.ts.map +1 -1
- package/dist/QueueMaker/SQLQueue.js +9 -7
- package/dist/QueueMaker/errors.d.ts +1 -1
- package/dist/QueueMaker/errors.d.ts.map +1 -1
- package/dist/QueueMaker/memQueue.d.ts.map +1 -1
- package/dist/QueueMaker/memQueue.js +10 -9
- package/dist/QueueMaker/sbqueue.d.ts.map +1 -1
- package/dist/QueueMaker/sbqueue.js +11 -9
- package/dist/RequestContext.d.ts +19 -14
- package/dist/RequestContext.d.ts.map +1 -1
- package/dist/RequestContext.js +5 -5
- package/dist/RequestFiberSet.d.ts +2 -2
- package/dist/RequestFiberSet.d.ts.map +1 -1
- package/dist/RequestFiberSet.js +5 -5
- package/dist/Store/ContextMapContainer.d.ts +14 -3
- package/dist/Store/ContextMapContainer.d.ts.map +1 -1
- package/dist/Store/ContextMapContainer.js +64 -3
- package/dist/Store/Cosmos.d.ts.map +1 -1
- package/dist/Store/Cosmos.js +55 -32
- package/dist/Store/Disk.d.ts.map +1 -1
- package/dist/Store/Disk.js +3 -4
- package/dist/Store/Memory.d.ts +2 -2
- package/dist/Store/Memory.d.ts.map +1 -1
- package/dist/Store/Memory.js +4 -4
- package/dist/Store/SQL/Pg.d.ts +4 -0
- package/dist/Store/SQL/Pg.d.ts.map +1 -0
- package/dist/Store/SQL/Pg.js +174 -0
- package/dist/Store/SQL/query.d.ts +34 -0
- package/dist/Store/SQL/query.d.ts.map +1 -0
- package/dist/Store/SQL/query.js +326 -0
- package/dist/Store/SQL.d.ts +4 -0
- package/dist/Store/SQL.d.ts.map +1 -0
- package/dist/Store/SQL.js +203 -0
- package/dist/Store/index.d.ts +1 -1
- package/dist/Store/index.d.ts.map +1 -1
- package/dist/Store/index.js +11 -1
- package/dist/Store/service.d.ts +8 -5
- package/dist/Store/service.d.ts.map +1 -1
- package/dist/Store/service.js +14 -6
- package/dist/adapters/SQL/Model.d.ts +2 -5
- package/dist/adapters/SQL/Model.d.ts.map +1 -1
- package/dist/adapters/SQL/Model.js +21 -13
- package/dist/adapters/ServiceBus.d.ts +6 -6
- package/dist/adapters/ServiceBus.d.ts.map +1 -1
- package/dist/adapters/ServiceBus.js +9 -9
- package/dist/adapters/cosmos-client.d.ts +2 -2
- package/dist/adapters/cosmos-client.d.ts.map +1 -1
- package/dist/adapters/cosmos-client.js +3 -3
- package/dist/adapters/logger.d.ts.map +1 -1
- package/dist/adapters/memQueue.d.ts +2 -2
- package/dist/adapters/memQueue.d.ts.map +1 -1
- package/dist/adapters/memQueue.js +3 -3
- package/dist/adapters/mongo-client.d.ts +2 -2
- package/dist/adapters/mongo-client.d.ts.map +1 -1
- package/dist/adapters/mongo-client.js +3 -3
- package/dist/adapters/redis-client.d.ts +3 -3
- package/dist/adapters/redis-client.d.ts.map +1 -1
- package/dist/adapters/redis-client.js +3 -3
- package/dist/api/ContextProvider.d.ts +6 -6
- package/dist/api/ContextProvider.d.ts.map +1 -1
- package/dist/api/ContextProvider.js +6 -6
- package/dist/api/internal/RequestContextMiddleware.d.ts +1 -1
- package/dist/api/internal/auth.d.ts +1 -1
- package/dist/api/internal/events.d.ts +2 -2
- package/dist/api/internal/events.d.ts.map +1 -1
- package/dist/api/internal/events.js +7 -5
- package/dist/api/layerUtils.d.ts +5 -5
- package/dist/api/layerUtils.d.ts.map +1 -1
- package/dist/api/layerUtils.js +5 -5
- package/dist/api/routing/middleware/RouterMiddleware.d.ts +3 -3
- package/dist/api/routing/middleware/RouterMiddleware.d.ts.map +1 -1
- package/dist/api/routing/middleware/middleware.d.ts +35 -1
- package/dist/api/routing/middleware/middleware.d.ts.map +1 -1
- package/dist/api/routing/middleware/middleware.js +39 -1
- package/dist/api/routing/schema/jwt.d.ts +1 -1
- package/dist/api/routing/schema/jwt.d.ts.map +1 -1
- package/dist/api/routing/schema/jwt.js +1 -1
- package/dist/api/routing.d.ts +1 -5
- package/dist/api/routing.d.ts.map +1 -1
- package/dist/api/routing.js +3 -2
- package/dist/api/setupRequest.d.ts +6 -3
- package/dist/api/setupRequest.d.ts.map +1 -1
- package/dist/api/setupRequest.js +11 -6
- package/dist/errorReporter.d.ts +1 -1
- package/dist/errorReporter.d.ts.map +1 -1
- package/dist/errorReporter.js +1 -1
- package/dist/fileUtil.js +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/rateLimit.js +1 -1
- package/examples/query.ts +29 -25
- package/package.json +32 -18
- package/src/CUPS.ts +2 -2
- package/src/Emailer/Sendgrid.ts +1 -1
- package/src/Emailer/service.ts +2 -2
- package/src/MainFiberSet.ts +2 -2
- package/src/Model/Repository/internal/internal.ts +11 -8
- package/src/Model/Repository/makeRepo.ts +2 -2
- package/src/Operations.ts +2 -2
- package/src/OperationsRepo.ts +2 -2
- package/src/QueueMaker/SQLQueue.ts +10 -10
- package/src/QueueMaker/memQueue.ts +41 -42
- package/src/QueueMaker/sbqueue.ts +65 -62
- package/src/RequestContext.ts +4 -4
- package/src/RequestFiberSet.ts +4 -4
- package/src/Store/ContextMapContainer.ts +98 -2
- package/src/Store/Cosmos.ts +207 -172
- package/src/Store/Disk.ts +2 -3
- package/src/Store/Memory.ts +4 -6
- package/src/Store/SQL/Pg.ts +294 -0
- package/src/Store/SQL/query.ts +372 -0
- package/src/Store/SQL.ts +327 -0
- package/src/Store/index.ts +10 -0
- package/src/Store/service.ts +16 -7
- package/src/adapters/SQL/Model.ts +76 -71
- package/src/adapters/ServiceBus.ts +8 -8
- package/src/adapters/cosmos-client.ts +2 -2
- package/src/adapters/memQueue.ts +2 -2
- package/src/adapters/mongo-client.ts +2 -2
- package/src/adapters/redis-client.ts +2 -2
- package/src/api/ContextProvider.ts +11 -11
- package/src/api/internal/events.ts +7 -6
- package/src/api/layerUtils.ts +8 -8
- package/src/api/routing/middleware/RouterMiddleware.ts +4 -4
- package/src/api/routing/middleware/middleware.ts +43 -0
- package/src/api/routing/schema/jwt.ts +2 -3
- package/src/api/routing.ts +7 -6
- package/src/api/setupRequest.ts +27 -7
- package/src/errorReporter.ts +1 -1
- package/src/fileUtil.ts +1 -1
- package/src/rateLimit.ts +2 -2
- package/test/contextProvider.test.ts +5 -5
- package/test/controller.test.ts +12 -9
- package/test/dist/contextProvider.test.d.ts.map +1 -1
- package/test/dist/controller.test.d.ts.map +1 -1
- package/test/dist/fixtures.d.ts +18 -8
- package/test/dist/fixtures.d.ts.map +1 -1
- package/test/dist/fixtures.js +11 -9
- package/test/dist/query.test.d.ts.map +1 -1
- package/test/dist/rawQuery.test.d.ts.map +1 -1
- package/test/dist/requires.test.d.ts.map +1 -1
- package/test/dist/rpc-multi-middleware.test.d.ts.map +1 -1
- package/test/dist/sql-store.test.d.ts.map +1 -0
- package/test/fixtures.ts +10 -8
- package/test/query.test.ts +160 -14
- package/test/rawQuery.test.ts +19 -17
- package/test/requires.test.ts +6 -5
- package/test/rpc-multi-middleware.test.ts +73 -4
- package/test/sql-store.test.ts +444 -0
- package/test/validateSample.test.ts +1 -1
- 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
|
+
}
|