@ultimabase/postgres 0.0.0-stage → 0.1.0

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 (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -2
  3. package/dist/adapter.d.ts +26 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +254 -0
  6. package/dist/aggregate.d.ts +8 -0
  7. package/dist/aggregate.d.ts.map +1 -0
  8. package/dist/aggregate.js +138 -0
  9. package/dist/decode.d.ts +5 -0
  10. package/dist/decode.d.ts.map +1 -0
  11. package/dist/decode.js +30 -0
  12. package/dist/fills.d.ts +16 -0
  13. package/dist/fills.d.ts.map +1 -0
  14. package/dist/fills.js +37 -0
  15. package/dist/filter.d.ts +50 -0
  16. package/dist/filter.d.ts.map +1 -0
  17. package/dist/filter.js +247 -0
  18. package/dist/index.d.ts +15 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +11 -0
  21. package/dist/json-filter.d.ts +12 -0
  22. package/dist/json-filter.d.ts.map +1 -0
  23. package/dist/json-filter.js +65 -0
  24. package/dist/listen.d.ts +10 -0
  25. package/dist/listen.d.ts.map +1 -0
  26. package/dist/listen.js +92 -0
  27. package/dist/logic.d.ts +7 -0
  28. package/dist/logic.d.ts.map +1 -0
  29. package/dist/logic.js +18 -0
  30. package/dist/migrations.d.ts +23 -0
  31. package/dist/migrations.d.ts.map +1 -0
  32. package/dist/migrations.js +217 -0
  33. package/dist/names.d.ts +66 -0
  34. package/dist/names.d.ts.map +1 -0
  35. package/dist/names.js +175 -0
  36. package/dist/notify.d.ts +19 -0
  37. package/dist/notify.d.ts.map +1 -0
  38. package/dist/notify.js +75 -0
  39. package/dist/numbers.d.ts +13 -0
  40. package/dist/numbers.d.ts.map +1 -0
  41. package/dist/numbers.js +49 -0
  42. package/dist/parts.d.ts +36 -0
  43. package/dist/parts.d.ts.map +1 -0
  44. package/dist/parts.js +99 -0
  45. package/dist/plan-indexes.d.ts +12 -0
  46. package/dist/plan-indexes.d.ts.map +1 -0
  47. package/dist/plan-indexes.js +49 -0
  48. package/dist/plan-parts.d.ts +16 -0
  49. package/dist/plan-parts.d.ts.map +1 -0
  50. package/dist/plan-parts.js +9 -0
  51. package/dist/plan.d.ts +6 -0
  52. package/dist/plan.d.ts.map +1 -0
  53. package/dist/plan.js +278 -0
  54. package/dist/pool.d.ts +5 -0
  55. package/dist/pool.d.ts.map +1 -0
  56. package/dist/pool.js +41 -0
  57. package/dist/read.d.ts +47 -0
  58. package/dist/read.d.ts.map +1 -0
  59. package/dist/read.js +227 -0
  60. package/dist/scratch.d.ts +22 -0
  61. package/dist/scratch.d.ts.map +1 -0
  62. package/dist/scratch.js +77 -0
  63. package/dist/snapshot.d.ts +66 -0
  64. package/dist/snapshot.d.ts.map +1 -0
  65. package/dist/snapshot.js +272 -0
  66. package/dist/write.d.ts +17 -0
  67. package/dist/write.d.ts.map +1 -0
  68. package/dist/write.js +296 -0
  69. package/package.json +45 -3
  70. package/src/adapter.ts +349 -0
  71. package/src/aggregate.ts +168 -0
  72. package/src/decode.ts +46 -0
  73. package/src/fills.ts +52 -0
  74. package/src/filter.ts +306 -0
  75. package/src/index.ts +20 -0
  76. package/src/json-filter.ts +74 -0
  77. package/src/listen.ts +97 -0
  78. package/src/logic.ts +28 -0
  79. package/src/migrations.ts +275 -0
  80. package/src/names.ts +217 -0
  81. package/src/notify.ts +101 -0
  82. package/src/numbers.ts +64 -0
  83. package/src/parts.ts +143 -0
  84. package/src/plan-indexes.ts +100 -0
  85. package/src/plan-parts.ts +35 -0
  86. package/src/plan.ts +389 -0
  87. package/src/pool.ts +63 -0
  88. package/src/read.ts +287 -0
  89. package/src/scratch.ts +92 -0
  90. package/src/snapshot.ts +390 -0
  91. package/src/write.ts +384 -0
package/src/adapter.ts ADDED
@@ -0,0 +1,349 @@
1
+ // The PostgreSQL adapter (D-47): Kysely builds the statements, `pg` runs
2
+ // them. `log` lets the tests count the statements (D-98); `sql` runs the
3
+ // application's own, and a test file may run in a schema of its own (D-137).
4
+ import type {
5
+ LogEvent,
6
+ QueryExecutorProvider,
7
+ Transaction as Open,
8
+ } from 'kysely'
9
+ import { Kysely, PostgresDialect, sql } from 'kysely'
10
+ import { Pool } from 'pg'
11
+ import { CheckFailed, Refused } from 'ultimabase'
12
+ import { joining, scratchable } from 'ultimabase/internal'
13
+ import type {
14
+ Adapter,
15
+ CountRead,
16
+ Feed,
17
+ Logger,
18
+ Read,
19
+ Row,
20
+ Schema,
21
+ Transaction,
22
+ Write,
23
+ } from 'ultimabase'
24
+ import { aggregate } from './aggregate.ts'
25
+ import { decodeRows } from './decode.ts'
26
+ import { apply, remoteOf, reset, stored } from './migrations.ts'
27
+ import { Catalog } from './names.ts'
28
+ import { overflowed } from './numbers.ts'
29
+ import { fillsOf } from './fills.ts'
30
+ import type { Listener } from './listen.ts'
31
+ import { channelOf, listening } from './listen.ts'
32
+ import { plan } from './plan.ts'
33
+ import { pooled } from './pool.ts'
34
+ import { compileCount, compileFind } from './read.ts'
35
+ import {
36
+ connecting,
37
+ dropped,
38
+ emptied,
39
+ scratchName,
40
+ trigrams,
41
+ } from './scratch.ts'
42
+ import { parseSnapshot, snapshotOf } from './snapshot.ts'
43
+ import type { Statement } from './write.ts'
44
+ import { compileWrite, translate } from './write.ts'
45
+
46
+ export type Options = {
47
+ readonly url: string | undefined
48
+ /** How many connections the pool opens at most (D-47). */
49
+ readonly connections?: number
50
+ /** The milliseconds a query waits for a connection, or to open one. */
51
+ readonly wait?: number
52
+ readonly log?: (event: LogEvent) => void
53
+ /** The text search configuration of every `_search`, `'simple'` by default (D-148). */
54
+ readonly searchLanguage?: string
55
+ }
56
+
57
+ /** What `postgres()` returns: an adapter that also runs raw SQL (D-137). */
58
+ export type Postgres = Adapter & {
59
+ /**
60
+ * Runs one statement, in the open transaction if any, and returns its
61
+ * rows; each `${value}` is a parameter. No rule or hook runs.
62
+ */
63
+ readonly sql: <R extends object = Record<string, unknown>>(
64
+ strings: TemplateStringsArray,
65
+ ...values: readonly unknown[]
66
+ ) => Promise<R[]>
67
+ }
68
+
69
+ /** What the callback a rollback ends throws, which `rollback()` swallows. */
70
+ const rolledBack = new Error('The transaction was rolled back.')
71
+
72
+ /** `key` of a statement's first row, if it returned one. */
73
+ function firstOf(rows: readonly unknown[], key: string): unknown {
74
+ const [first] = rows
75
+ if (typeof first !== 'object' || first === null) return undefined
76
+ const value: unknown = Reflect.get(first, key)
77
+ return value
78
+ }
79
+
80
+ async function find(
81
+ catalog: Catalog,
82
+ db: QueryExecutorProvider,
83
+ read: Read,
84
+ ): Promise<Row[]> {
85
+ const { rows } = await compileFind(catalog, read).execute(db)
86
+ return decodeRows(catalog, read.model, firstOf(rows, 'rows'), read)
87
+ }
88
+
89
+ async function count(
90
+ catalog: Catalog,
91
+ db: QueryExecutorProvider,
92
+ read: CountRead,
93
+ ): Promise<number> {
94
+ const { rows } = await compileCount(catalog, read).execute(db)
95
+ const value = firstOf(rows, 'count')
96
+ if (typeof value !== 'number') throw new Error('The count is not a number.')
97
+ return value
98
+ }
99
+
100
+ /** Runs a write's statements; the one that reads returns the rows. */
101
+ async function run(
102
+ catalog: Catalog,
103
+ db: QueryExecutorProvider,
104
+ given: Write,
105
+ statements: readonly Statement[],
106
+ ): Promise<Row[]> {
107
+ let rows: unknown
108
+ try {
109
+ for (const statement of statements) {
110
+ const [first] = (await statement.sql.execute(db)).rows
111
+ for (let at = 0; at < statement.checks; at += 1) {
112
+ if (first?.[`c${at}`] !== true) throw new CheckFailed(at)
113
+ }
114
+ if (first !== undefined && 'rows' in first) rows = first['rows']
115
+ }
116
+ } catch (error) {
117
+ throw translate(catalog, overflowed(given, error))
118
+ }
119
+ const read = given.read
120
+ // A `before` read of a model the write deletes nothing of reads none.
121
+ if (read === undefined || rows === undefined) return []
122
+ return decodeRows(catalog, read.model, rows, read)
123
+ }
124
+
125
+ async function write(
126
+ catalog: Catalog,
127
+ db: QueryExecutorProvider,
128
+ given: Write,
129
+ channel: string | undefined,
130
+ ): Promise<Row[]> {
131
+ return run(catalog, db, given, compileWrite(catalog, given, channel))
132
+ }
133
+
134
+ /** Refuses a server below PostgreSQL 15 (D-50). */
135
+ export function requireVersion(version: string | undefined): void {
136
+ if (!(Number(version) >= 150000)) {
137
+ throw new Refused(`UltimaBase needs PostgreSQL 15 or later.`)
138
+ }
139
+ }
140
+
141
+ /** What a case-insensitive filter misses when `lower('É') <> 'é'` (D-140). */
142
+ export function foldWarning(ctype: string): string {
143
+ return `PostgreSQL folds case under its ${ctype} locale for ASCII only: a case-insensitive filter matches É and é in memory() but not here. Create the database with a UTF-8 locale, such as en_US.UTF-8.`
144
+ }
145
+
146
+ export function createAdapter(options: Options): Postgres {
147
+ const { url, connections = 10, wait = 10_000, searchLanguage } = options
148
+ const remote = url ? remoteOf(url) : undefined
149
+ let state:
150
+ | {
151
+ db: Kysely<object>
152
+ catalog: Catalog
153
+ /** The feed's channel and listener, when it has a taker (D-154). */
154
+ channel?: string
155
+ listener?: Listener
156
+ }
157
+ | undefined
158
+ /** A test file's schema, and whether a start created it. */
159
+ let scratch: { readonly name: string; made: boolean } | undefined
160
+ /** Each open transaction's own, for `sql` to join. */
161
+ const opens = new WeakMap<Transaction, Open<object>>()
162
+ const started = () => {
163
+ if (state === undefined) throw new Error('postgres() is not started.')
164
+ return state
165
+ }
166
+ const adapter: Postgres = {
167
+ start: async (schema: Schema, logger: Logger, feed?: Feed) => {
168
+ if (!url) {
169
+ throw new Refused(
170
+ 'postgres() was given no url: is the variable it reads set?',
171
+ )
172
+ }
173
+ if (!Number.isInteger(connections) || connections < 1) {
174
+ throw new Refused(
175
+ `postgres() takes connections as a whole number, 1 or more, not ${connections}.`,
176
+ )
177
+ }
178
+ const catalog = new Catalog(schema, searchLanguage)
179
+ // Refuses two things stored under one name before it connects.
180
+ const { tables } = snapshotOf(catalog)
181
+ const trigram = Object.values(tables).some(
182
+ (table) => Object.keys(table.trigrams).length > 0,
183
+ )
184
+ const extensions =
185
+ scratch !== undefined && trigram ? [await trigrams(url)] : []
186
+ const config = connecting(url, scratch?.name, extensions)
187
+ const pool = new Pool({
188
+ ...config,
189
+ max: connections,
190
+ connectionTimeoutMillis: wait,
191
+ })
192
+ // A connection the server ends would crash the process unheard. An
193
+ // idle one the pool drops, and the next query opens another; one a
194
+ // transaction holds fails its next statement, and the pool drops it
195
+ // once given back.
196
+ pool.on('error', () => undefined)
197
+ pool.on('connect', (client) => {
198
+ client.on('error', () => undefined)
199
+ // Compiling a nested read costs a hundred times more than running
200
+ // it; queued, this runs before the client's first query (D-47).
201
+ client.query('set jit = off').catch(() => undefined)
202
+ })
203
+ const dialect = new PostgresDialect({
204
+ pool: pooled(url, pool, connections, wait),
205
+ })
206
+ const db = new Kysely<object>({
207
+ dialect,
208
+ ...(options.log === undefined ? {} : { log: options.log }),
209
+ })
210
+ try {
211
+ const { rows } = await sql<{
212
+ version: string
213
+ folds: boolean
214
+ ctype: string
215
+ language: boolean
216
+ schema: string
217
+ }>`select current_setting('server_version_num') as version, current_schema() as schema,
218
+ lower('É') = 'é' as folds,
219
+ (select datctype from pg_database where datname = current_database()) as ctype,
220
+ exists (select from pg_ts_config as c join pg_namespace as n on n.oid = c.cfgnamespace where (c.cfgname = ${catalog.language} and pg_ts_config_is_visible(c.oid)) or n.nspname || '.' || c.cfgname = ${catalog.language}) as language`.execute(
221
+ db,
222
+ )
223
+ const [server] = rows
224
+ requireVersion(server?.version)
225
+ if (server?.language === false) {
226
+ throw new Refused(
227
+ `postgres() searches in '${catalog.language}', a text search configuration this server does not have.`,
228
+ )
229
+ }
230
+ if (server !== undefined && !server.folds) {
231
+ logger.warn(foldWarning(server.ctype))
232
+ }
233
+ if (scratch !== undefined) {
234
+ scratch.made = true
235
+ await emptied(db, scratch.name)
236
+ }
237
+ const channel = channelOf(scratch?.name ?? server?.schema ?? '')
238
+ state =
239
+ feed === undefined
240
+ ? { db, catalog }
241
+ : {
242
+ db,
243
+ catalog,
244
+ channel,
245
+ listener: await listening(config, channel, feed, logger),
246
+ }
247
+ } catch (error) {
248
+ await db.destroy()
249
+ throw error
250
+ }
251
+ },
252
+ stop: async () => {
253
+ const stopping = state
254
+ state = undefined
255
+ await stopping?.listener?.stop()
256
+ await stopping?.db.destroy()
257
+ },
258
+ find: async (read) => find(started().catalog, started().db, read),
259
+ count: async (read) => count(started().catalog, started().db, read),
260
+ aggregate: async (read) => aggregate(started().catalog, started().db, read),
261
+ write: async (given) => {
262
+ const { catalog, db, channel } = started()
263
+ const statements = compileWrite(catalog, given, channel)
264
+ // One statement is atomic alone: no BEGIN, no COMMIT (D-99).
265
+ if (statements.length <= 1) return run(catalog, db, given, statements)
266
+ try {
267
+ return await db
268
+ .transaction()
269
+ .execute(async (trx) => run(catalog, trx, given, statements))
270
+ } catch (error) {
271
+ // A deferred foreign key checks at the COMMIT (D-99).
272
+ throw translate(catalog, error)
273
+ }
274
+ },
275
+ migrations: {
276
+ snapshot: (schema) => snapshotOf(new Catalog(schema, searchLanguage)),
277
+ plan: (from, schema) => {
278
+ const catalog = new Catalog(schema, searchLanguage)
279
+ return plan(
280
+ from === undefined ? undefined : parseSnapshot(from),
281
+ snapshotOf(catalog),
282
+ fillsOf(catalog),
283
+ )
284
+ },
285
+ stored: async () => stored(started().db),
286
+ apply: async (given) => apply(started().db, given),
287
+ reset: async () => reset(started().db),
288
+ ...(remote === undefined ? {} : { remote }),
289
+ },
290
+ begin: async (): Promise<Transaction> => {
291
+ const { catalog, db, channel } = started()
292
+ const opened = Promise.withResolvers<Open<object>>()
293
+ const ending = Promise.withResolvers<boolean>()
294
+ // Kysely gives the connection back however its transaction ends, a
295
+ // failed ROLLBACK included; a COMMIT that fails rolls back.
296
+ const ended = db.transaction().execute(async (trx) => {
297
+ opened.resolve(trx)
298
+ if (!(await ending.promise)) throw rolledBack
299
+ })
300
+ // A BEGIN that fails rejects `opened` too.
301
+ void ended.catch(opened.reject)
302
+ const trx = await opened.promise
303
+ const end = async (commit: boolean) => {
304
+ ending.resolve(commit)
305
+ await ended
306
+ }
307
+ const tx: Transaction = {
308
+ find: async (read) => find(catalog, trx, read),
309
+ count: async (read) => count(catalog, trx, read),
310
+ aggregate: async (read) => aggregate(catalog, trx, read),
311
+ write: async (given) => write(catalog, trx, given, channel),
312
+ commit: async () => {
313
+ try {
314
+ await end(true)
315
+ } catch (error) {
316
+ throw translate(catalog, error)
317
+ }
318
+ },
319
+ rollback: async () => {
320
+ try {
321
+ await end(false)
322
+ } catch (error) {
323
+ if (error !== rolledBack) throw error
324
+ }
325
+ },
326
+ }
327
+ opens.set(tx, trx)
328
+ return tx
329
+ },
330
+ sql: async <R extends object>(
331
+ strings: TemplateStringsArray,
332
+ ...values: readonly unknown[]
333
+ ) =>
334
+ joining(adapter, async (tx) => {
335
+ const db = (tx && opens.get(tx)) ?? started().db
336
+ return (await sql<R>(strings, ...values).execute(db)).rows
337
+ }),
338
+ }
339
+ return scratchable(adapter, () => {
340
+ scratch = { name: scratchName(), made: false }
341
+ const own = scratch
342
+ return {
343
+ drop: async () => {
344
+ scratch = undefined
345
+ if (own.made && url) await dropped(url, own.name)
346
+ },
347
+ }
348
+ })
349
+ }
@@ -0,0 +1,168 @@
1
+ // An aggregate as one statement (D-143), in groups (D-146): an inner select
2
+ // gives each row's keys and aggregated columns, the outer one groups, keeps
3
+ // those `having` passes (D-149), sorts and pages them. Each group's values
4
+ // are a jsonb array: a sum and an average as float8, a date in UTC as a read
5
+ // returns it, a decimal as text, exact (D-145).
6
+ import type { QueryExecutorProvider } from 'kysely'
7
+ import { sql } from 'kysely'
8
+ import type { Aggregate, AggregateRead, Filter, GroupKey } from 'ultimabase'
9
+ import type { Sql } from './filter.ts'
10
+ import { all, any, isFilters } from './filter.ts'
11
+ import type { Catalog, Kind, Table } from './names.ts'
12
+ import { Compiler, sorted } from './read.ts'
13
+
14
+ /** A value as jsonb keeps it: a decimal as text, a date in UTC. */
15
+ function shown(kind: Kind | undefined, item: Sql): Sql {
16
+ if (kind === 'decimal') return sql`cast(${item} as text)`
17
+ return kind === 'dateTime' ? sql`(${item} at time zone 'UTC')` : item
18
+ }
19
+
20
+ /** The aggregate of `column`, as PostgreSQL computes it. */
21
+ function computed({ operation, field }: Aggregate, column: Sql): Sql {
22
+ if (field === undefined) return sql`count(*)`
23
+ return sql`${sql.raw(operation)}(${column})`
24
+ }
25
+
26
+ /** The aggregate as jsonb keeps it; a sum and an average as float8. */
27
+ function value(kind: Kind | undefined, asked: Aggregate, column: Sql): Sql {
28
+ const result = computed(asked, column)
29
+ const { operation, field } = asked
30
+ if (field === undefined || operation === 'count') return result
31
+ if (kind === 'decimal' && operation !== 'avg') return shown(kind, result)
32
+ if (operation === 'sum' || operation === 'avg') {
33
+ return sql`cast(${result} as float8)`
34
+ }
35
+ return shown(kind, result)
36
+ }
37
+
38
+ /** The kind of a key's value: its field's, on the model `through` reaches. */
39
+ function kindOf(catalog: Catalog, table: Table, key: GroupKey): Kind {
40
+ let at = table
41
+ for (const step of key.through ?? []) {
42
+ const field = at.fields.get(step.relation)
43
+ if (field === undefined || field.kind === 'scalar') {
44
+ throw new Error(`${at.model.name}.${step.relation} is not a relation.`)
45
+ }
46
+ at = catalog.table(field.target)
47
+ }
48
+ return catalog.kind(at, key.field)
49
+ }
50
+
51
+ /** Each aggregate's kind, `undefined` for a count of rows. */
52
+ function kinds(
53
+ catalog: Catalog,
54
+ table: Table,
55
+ read: AggregateRead,
56
+ ): (Kind | undefined)[] {
57
+ return read.aggregates.map(({ field }) =>
58
+ field === undefined ? undefined : catalog.kind(table, field),
59
+ )
60
+ }
61
+
62
+ export function compileAggregate(catalog: Catalog, read: AggregateRead): Sql {
63
+ const compiler = new Compiler(catalog)
64
+ const { filters } = compiler
65
+ const alias = filters.alias()
66
+ const grouped = filters.alias()
67
+ const table = catalog.table(read.model)
68
+ const by = read.by ?? []
69
+ const inner = [
70
+ ...by.map(
71
+ (key, at) =>
72
+ sql`${compiler.key(table, alias, key)} as ${sql.id(`k${at}`)}`,
73
+ ),
74
+ ...read.aggregates.flatMap(({ field }, at) =>
75
+ field === undefined
76
+ ? []
77
+ : [sql`${filters.column(table, alias, field)} as ${sql.id(`c${at}`)}`],
78
+ ),
79
+ ]
80
+ const keys = by.map((_, at) => sql.id(grouped, `k${at}`))
81
+ const types = kinds(catalog, table, read)
82
+ const aggregates = read.aggregates.map((each, at) => {
83
+ const column = sql.id(grouped, `c${at}`)
84
+ return { kind: types[at], each, column }
85
+ })
86
+ const values = [
87
+ ...by.map((key, at) =>
88
+ shown(kindOf(catalog, table, key), sql.id(grouped, `k${at}`)),
89
+ ),
90
+ ...aggregates.map(({ kind, each, column }) => value(kind, each, column)),
91
+ ]
92
+ // PostgreSQL takes at most 100 arguments per call.
93
+ const arrays: Sql[] = []
94
+ for (let at = 0; at < values.length; at += 100) {
95
+ arrays.push(sql`jsonb_build_array(${sql.join(values.slice(at, at + 100))})`)
96
+ }
97
+ const where = filters.where(read.model, alias, read.where)
98
+ const rows = sql`(select ${sql.join([sql`1`, ...inner])} from ${sql.id(table.name)} as ${sql.id(alias)} where ${where}) as ${sql.id(grouped)}`
99
+ const group = by.length === 0 ? sql`` : sql` group by ${sql.join(keys)}`
100
+ // A key or an aggregate, by its position.
101
+ const at = (index: number): { readonly item: Sql; readonly kind: Kind } => {
102
+ const found = aggregates[index - by.length]
103
+ const key = by[index]
104
+ if (found === undefined) {
105
+ if (key === undefined) throw new Error(`No value at ${index}.`)
106
+ const kind = kindOf(catalog, table, key)
107
+ return { item: sql.id(grouped, `k${index}`), kind }
108
+ }
109
+ const { operation } = found.each
110
+ const kind =
111
+ operation === 'min' || operation === 'max'
112
+ ? (found.kind ?? 'number')
113
+ : operation === 'sum' && found.kind === 'decimal'
114
+ ? 'decimal'
115
+ : 'number'
116
+ return { item: computed(found.each, found.column), kind }
117
+ }
118
+ const kept = (filter: Filter): Sql =>
119
+ all(Object.entries(filter).map(([key, part]) => condition(key, part)))
120
+ function condition(key: string, part: Filter[string]): Sql {
121
+ if (part === undefined) return sql`true`
122
+ if (!isFilters(part)) {
123
+ const { item, kind } = at(Number(key))
124
+ return filters.compare(item, kind, part)
125
+ }
126
+ const list = part.map((each) => kept(each))
127
+ if (key === 'AND') return all(list)
128
+ if (key === 'OR') return any(list)
129
+ return all(list.map((each) => sql`(not ${each})`))
130
+ }
131
+ const having =
132
+ read.having === undefined ? sql`` : sql` having ${kept(read.having)}`
133
+ const sorts = (read.orderBy ?? []).map((each) =>
134
+ sorted(at(each.at).item, each),
135
+ )
136
+ const order = sorts.length === 0 ? sql`` : sql` order by ${sql.join(sorts)}`
137
+ const limit = read.take === undefined ? sql`` : sql` limit ${read.take}`
138
+ const offset = read.skip === undefined ? sql`` : sql` offset ${read.skip}`
139
+ return sql`select ${sql.join(arrays, sql` || `)} as "values" from ${rows}${group}${having}${order}${limit}${offset}`
140
+ }
141
+
142
+ /** Each group's values, a date back as a `Date`. */
143
+ export async function aggregate(
144
+ catalog: Catalog,
145
+ db: QueryExecutorProvider,
146
+ read: AggregateRead,
147
+ ): Promise<unknown[][]> {
148
+ const { rows } = await compileAggregate(catalog, read).execute(db)
149
+ const table = catalog.table(read.model)
150
+ const by = read.by ?? []
151
+ const types = [
152
+ ...by.map((key) => kindOf(catalog, table, key)),
153
+ ...kinds(catalog, table, read),
154
+ ]
155
+ return rows.map((row) => {
156
+ const values: unknown =
157
+ typeof row === 'object' && row !== null
158
+ ? Reflect.get(row, 'values')
159
+ : undefined
160
+ if (!Array.isArray(values)) throw new Error('The values are not a list.')
161
+ return types.map((kind, index): unknown => {
162
+ const item: unknown = values[index]
163
+ return typeof item === 'string' && kind === 'dateTime'
164
+ ? new Date(`${item}Z`)
165
+ : item
166
+ })
167
+ })
168
+ }
package/src/decode.ts ADDED
@@ -0,0 +1,46 @@
1
+ // What a read's jsonb holds, back as the rows the kernel expects (D-73): a
2
+ // date arrives as text in UTC without its zone, everything else as written.
3
+ import type { Nested, Row } from 'ultimabase'
4
+ import { isRow } from 'ultimabase/internal'
5
+ import type { Catalog } from './names.ts'
6
+
7
+ const dates = new Set(['createdAt', 'updatedAt'])
8
+
9
+ function decodeRow(
10
+ catalog: Catalog,
11
+ model: string,
12
+ value: unknown,
13
+ nested: Nested,
14
+ ): Row {
15
+ if (!isRow(value)) throw new Error(`A ${model} row has no id.`)
16
+ const table = catalog.table(model)
17
+ const row: { id: string; [field: string]: unknown } = { ...value }
18
+ for (const [name, item] of Object.entries(value)) {
19
+ const field = table.fields.get(name)
20
+ const inner = nested.include?.[name]
21
+ if (
22
+ typeof item === 'string' &&
23
+ (dates.has(name) ||
24
+ (field?.kind === 'scalar' && field.type === 'dateTime'))
25
+ ) {
26
+ row[name] = new Date(`${item}Z`)
27
+ } else if (inner !== undefined && field?.kind === 'many') {
28
+ row[name] = decodeRows(catalog, field.target, item, inner)
29
+ } else if (inner !== undefined && field?.kind === 'one' && item !== null) {
30
+ row[name] = decodeRow(catalog, field.target, item, inner)
31
+ }
32
+ }
33
+ return row
34
+ }
35
+
36
+ /** The rows of a jsonb array of `model`, with the relations `nested` includes. */
37
+ export function decodeRows(
38
+ catalog: Catalog,
39
+ model: string,
40
+ value: unknown,
41
+ nested: Nested,
42
+ ): Row[] {
43
+ if (!Array.isArray(value))
44
+ throw new Error(`The ${model} rows are not a list.`)
45
+ return value.map((item: unknown) => decodeRow(catalog, model, item, nested))
46
+ }
package/src/fills.ts ADDED
@@ -0,0 +1,52 @@
1
+ // The fixed defaults a column added to a table with rows fills them with
2
+ // (D-105); a list's is `[]` when it has none (D-120, D-127).
3
+ import type { Catalog, Kind } from './names.ts'
4
+
5
+ export const literal = (value: string): string =>
6
+ `'${value.replaceAll("'", "''")}'`
7
+
8
+ /** A fixed default as SQL, and as the log shows it: `'draft'`. */
9
+ export type Fill = { readonly sql: string; readonly shown: string }
10
+
11
+ /** Each table's columns with a fixed default (D-105). */
12
+ export type Fills = {
13
+ readonly [table: string]: { readonly [column: string]: Fill }
14
+ }
15
+
16
+ function fillOf(kind: Kind, value: unknown): Fill {
17
+ if (kind.endsWith('[]') && Array.isArray(value)) {
18
+ const items = value.map(
19
+ (item) => `"${String(item).replaceAll(/[\\"]/g, '\\$&')}"`,
20
+ )
21
+ const text = literal(`{${items.join(',')}}`)
22
+ return { sql: `${text}::${kind}`, shown: text }
23
+ }
24
+ if (kind === 'json') {
25
+ const text = literal(JSON.stringify(value))
26
+ return { sql: `${text}::jsonb`, shown: text }
27
+ }
28
+ if (value instanceof Date) {
29
+ const text = literal(value.toISOString())
30
+ return { sql: `${text}::timestamptz`, shown: text }
31
+ }
32
+ const text = typeof value === 'string' ? literal(value) : String(value)
33
+ return { sql: text, shown: text }
34
+ }
35
+
36
+ /** The columns whose field has a fixed default, which rows take when added. */
37
+ export function fillsOf(catalog: Catalog): Fills {
38
+ const fills: Record<string, Record<string, Fill>> = {}
39
+ for (const table of catalog.tables.values()) {
40
+ const columns: Record<string, Fill> = {}
41
+ for (const field of table.model.fields) {
42
+ if (field.kind !== 'scalar') continue
43
+ // A list's rows hold `[]` when nothing else is given (D-120).
44
+ const given = field.list ? (field.default ?? []) : field.default
45
+ if (given === undefined) continue
46
+ const kind = catalog.kind(table, field.name)
47
+ columns[catalog.column(table, field.name)] = fillOf(kind, given)
48
+ }
49
+ fills[table.name] = columns
50
+ }
51
+ return fills
52
+ }