@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.
- package/CHANGELOG.md +12 -0
- package/README.md +4 -2
- package/dist/adapter.d.ts +26 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +254 -0
- package/dist/aggregate.d.ts +8 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +138 -0
- package/dist/decode.d.ts +5 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +30 -0
- package/dist/fills.d.ts +16 -0
- package/dist/fills.d.ts.map +1 -0
- package/dist/fills.js +37 -0
- package/dist/filter.d.ts +50 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +247 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/json-filter.d.ts +12 -0
- package/dist/json-filter.d.ts.map +1 -0
- package/dist/json-filter.js +65 -0
- package/dist/listen.d.ts +10 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +92 -0
- package/dist/logic.d.ts +7 -0
- package/dist/logic.d.ts.map +1 -0
- package/dist/logic.js +18 -0
- package/dist/migrations.d.ts +23 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +217 -0
- package/dist/names.d.ts +66 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +175 -0
- package/dist/notify.d.ts +19 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +75 -0
- package/dist/numbers.d.ts +13 -0
- package/dist/numbers.d.ts.map +1 -0
- package/dist/numbers.js +49 -0
- package/dist/parts.d.ts +36 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +99 -0
- package/dist/plan-indexes.d.ts +12 -0
- package/dist/plan-indexes.d.ts.map +1 -0
- package/dist/plan-indexes.js +49 -0
- package/dist/plan-parts.d.ts +16 -0
- package/dist/plan-parts.d.ts.map +1 -0
- package/dist/plan-parts.js +9 -0
- package/dist/plan.d.ts +6 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +278 -0
- package/dist/pool.d.ts +5 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +41 -0
- package/dist/read.d.ts +47 -0
- package/dist/read.d.ts.map +1 -0
- package/dist/read.js +227 -0
- package/dist/scratch.d.ts +22 -0
- package/dist/scratch.d.ts.map +1 -0
- package/dist/scratch.js +77 -0
- package/dist/snapshot.d.ts +66 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +272 -0
- package/dist/write.d.ts +17 -0
- package/dist/write.d.ts.map +1 -0
- package/dist/write.js +296 -0
- package/package.json +45 -3
- package/src/adapter.ts +349 -0
- package/src/aggregate.ts +168 -0
- package/src/decode.ts +46 -0
- package/src/fills.ts +52 -0
- package/src/filter.ts +306 -0
- package/src/index.ts +20 -0
- package/src/json-filter.ts +74 -0
- package/src/listen.ts +97 -0
- package/src/logic.ts +28 -0
- package/src/migrations.ts +275 -0
- package/src/names.ts +217 -0
- package/src/notify.ts +101 -0
- package/src/numbers.ts +64 -0
- package/src/parts.ts +143 -0
- package/src/plan-indexes.ts +100 -0
- package/src/plan-parts.ts +35 -0
- package/src/plan.ts +389 -0
- package/src/pool.ts +63 -0
- package/src/read.ts +287 -0
- package/src/scratch.ts +92 -0
- package/src/snapshot.ts +390 -0
- package/src/write.ts +384 -0
package/src/pool.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The pool's connections, whose failures say why (D-105).
|
|
2
|
+
import type { PostgresPool } from 'kysely'
|
|
3
|
+
import type { Pool } from 'pg'
|
|
4
|
+
import { parse } from 'pg-connection-string'
|
|
5
|
+
|
|
6
|
+
/** The codes of a server the network does not reach, or does not know. */
|
|
7
|
+
const unreachable =
|
|
8
|
+
/^(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH)$/
|
|
9
|
+
|
|
10
|
+
/** What pg-pool throws when no connection frees, or opens, in time. */
|
|
11
|
+
const busy = 'timeout exceeded when trying to connect'
|
|
12
|
+
const silent = 'Connection terminated due to connection timeout'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `error`, or one naming the server `url` cannot reach, not its password
|
|
16
|
+
* (D-105), or the connections that stayed busy.
|
|
17
|
+
*/
|
|
18
|
+
function failed(
|
|
19
|
+
url: string,
|
|
20
|
+
error: unknown,
|
|
21
|
+
connections: number,
|
|
22
|
+
wait: number,
|
|
23
|
+
): unknown {
|
|
24
|
+
const message = error instanceof Error ? error.message : ''
|
|
25
|
+
const seconds = `${wait / 1000}s`
|
|
26
|
+
if (message === busy) {
|
|
27
|
+
return new Error(
|
|
28
|
+
`Every one of the ${connections} PostgreSQL connections stayed busy for ${seconds}: a transaction may wait on something slow or never end.`,
|
|
29
|
+
{ cause: error },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const code: unknown = Reflect.get(Object(error), 'code')
|
|
33
|
+
const reachable = typeof code !== 'string' || !unreachable.test(code)
|
|
34
|
+
if (reachable && message !== silent) return error
|
|
35
|
+
const { host, port } = parse(url)
|
|
36
|
+
const address = `${host || process.env['PGHOST'] || 'localhost'}:${port || 5432}`
|
|
37
|
+
const why = reachable ? `no answer in ${seconds}` : code
|
|
38
|
+
const reached = new Error(
|
|
39
|
+
`Cannot reach PostgreSQL at ${address} (${why}): is it running, and is its url right?`,
|
|
40
|
+
{ cause: error },
|
|
41
|
+
)
|
|
42
|
+
return reachable ? reached : Object.assign(reached, { code })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `pool`, whose failures to give a connection say why. */
|
|
46
|
+
export function pooled(
|
|
47
|
+
url: string,
|
|
48
|
+
pool: Pool,
|
|
49
|
+
connections: number,
|
|
50
|
+
wait: number,
|
|
51
|
+
): PostgresPool {
|
|
52
|
+
return {
|
|
53
|
+
options: pool.options,
|
|
54
|
+
connect: async () => {
|
|
55
|
+
try {
|
|
56
|
+
return await pool.connect()
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw failed(url, error, connections, wait)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
end: async () => pool.end(),
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/read.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// A read as one statement (D-46): every list, the root's and each included
|
|
2
|
+
// `many`, is a jsonb array the database builds, so relations at any depth
|
|
3
|
+
// cost no extra round trip.
|
|
4
|
+
import { sql } from 'kysely'
|
|
5
|
+
import type {
|
|
6
|
+
CountRead,
|
|
7
|
+
Filter,
|
|
8
|
+
GroupKey,
|
|
9
|
+
Nested,
|
|
10
|
+
Order,
|
|
11
|
+
Read,
|
|
12
|
+
Sort,
|
|
13
|
+
} from 'ultimabase'
|
|
14
|
+
import type { Reach, Sql } from './filter.ts'
|
|
15
|
+
import { all, Filters } from './filter.ts'
|
|
16
|
+
import type { Catalog, Table } from './names.ts'
|
|
17
|
+
|
|
18
|
+
const empty = sql`cast('[]' as jsonb)`
|
|
19
|
+
|
|
20
|
+
/** PostgreSQL takes at most 100 arguments: 50 pairs per `jsonb_build_object`. */
|
|
21
|
+
function object(pairs: readonly (readonly [string, Sql])[]): Sql {
|
|
22
|
+
const chunks: Sql[] = []
|
|
23
|
+
for (let at = 0; at < pairs.length; at += 50) {
|
|
24
|
+
const items = pairs
|
|
25
|
+
.slice(at, at + 50)
|
|
26
|
+
.flatMap(([key, item]) => [sql.lit(key), item])
|
|
27
|
+
chunks.push(sql`jsonb_build_object(${sql.join(items)})`)
|
|
28
|
+
}
|
|
29
|
+
return sql.join(chunks, sql` || `)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const flip = (order: Order): Order => ({
|
|
33
|
+
...order,
|
|
34
|
+
direction: order.direction === 'asc' ? 'desc' : 'asc',
|
|
35
|
+
...(order.nulls && { nulls: order.nulls === 'first' ? 'last' : 'first' }),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Whether `null` sorts after every value (D-149). */
|
|
39
|
+
const nullsLast = ({ direction, nulls }: Sort) =>
|
|
40
|
+
(nulls ?? (direction === 'asc' ? 'last' : 'first')) === 'last'
|
|
41
|
+
|
|
42
|
+
/** A sort key in `ORDER BY`; PostgreSQL's default `null`s are D-94's. */
|
|
43
|
+
export const sorted = (key: Sql, { direction, nulls }: Sort): Sql =>
|
|
44
|
+
sql`${key} ${sql.raw(direction)}${nulls ? sql.raw(` nulls ${nulls}`) : sql``}`
|
|
45
|
+
|
|
46
|
+
export class Compiler {
|
|
47
|
+
readonly filters: Filters
|
|
48
|
+
readonly catalog: Catalog
|
|
49
|
+
|
|
50
|
+
constructor(catalog: Catalog) {
|
|
51
|
+
this.catalog = catalog
|
|
52
|
+
this.filters = new Filters(catalog)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The rows `nested` selects among those `reach` gives, as a jsonb array. */
|
|
56
|
+
list(model: string, nested: Nested, reach: Reach, alias: string): Sql {
|
|
57
|
+
const take = nested.take
|
|
58
|
+
if (take === 0) return empty
|
|
59
|
+
const table = this.catalog.table(model)
|
|
60
|
+
const backward = take !== undefined && take < 0
|
|
61
|
+
const given = nested.orderBy ?? [{ field: 'id', direction: 'asc' }]
|
|
62
|
+
const order = backward ? given.map(flip) : given
|
|
63
|
+
const by = sql.join(
|
|
64
|
+
order.map((each) => sorted(this.key(table, alias, each), each)),
|
|
65
|
+
)
|
|
66
|
+
const conditions = [
|
|
67
|
+
reach.on,
|
|
68
|
+
this.filters.where(model, alias, nested.where),
|
|
69
|
+
]
|
|
70
|
+
let from = reach.from
|
|
71
|
+
if (nested.cursor !== undefined) {
|
|
72
|
+
this.filters.tables.add(table.name)
|
|
73
|
+
const cursor = this.filters.alias()
|
|
74
|
+
const found = this.filters.alias()
|
|
75
|
+
const keys = order.map(
|
|
76
|
+
(each, at) =>
|
|
77
|
+
sql`${this.key(table, found, each)} as ${sql.id(`k${at}`)}`,
|
|
78
|
+
)
|
|
79
|
+
const where = this.filters.where(model, found, nested.cursor)
|
|
80
|
+
from = sql`${from} cross join (select ${sql.join(keys)} from ${sql.id(table.name)} as ${sql.id(found)} where ${where} limit 1) as ${sql.id(cursor)}`
|
|
81
|
+
conditions.push(this.keyset(table, alias, cursor, order))
|
|
82
|
+
}
|
|
83
|
+
const limit = take === undefined ? sql`` : sql` limit ${Math.abs(take)}`
|
|
84
|
+
const skip = nested.skip ?? 0
|
|
85
|
+
const offset = skip === 0 ? sql`` : sql` offset ${skip}`
|
|
86
|
+
const row = this.row(model, alias, nested)
|
|
87
|
+
const rows = sql`select ${row} as "r", row_number() over (order by ${by}) as "n" from ${from} where ${all(conditions)} order by ${by}${limit}${offset}`
|
|
88
|
+
const sequence = backward ? sql`"n" desc` : sql`"n"`
|
|
89
|
+
return sql`(select coalesce(jsonb_agg("q"."r" order by ${sequence}), ${empty}) from (${rows}) as "q")`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The rows at or after the cursor in `order`, `null` where it sorts, as
|
|
94
|
+
* `memory()` compares: `a ≥ c` is `a > c`, or `a = c` and the next key
|
|
95
|
+
* decides.
|
|
96
|
+
*/
|
|
97
|
+
private keyset(
|
|
98
|
+
table: Table,
|
|
99
|
+
alias: string,
|
|
100
|
+
cursor: string,
|
|
101
|
+
order: readonly Order[],
|
|
102
|
+
): Sql {
|
|
103
|
+
let result: Sql = sql`true`
|
|
104
|
+
for (const [at, each] of [...order.entries()].toReversed()) {
|
|
105
|
+
const a = this.key(table, alias, each)
|
|
106
|
+
const c = sql.id(cursor, `k${at}`)
|
|
107
|
+
const beyond =
|
|
108
|
+
each.direction === 'asc' ? sql`${a} > ${c}` : sql`${a} < ${c}`
|
|
109
|
+
const nulls = nullsLast(each)
|
|
110
|
+
? sql`(${a} is null and ${c} is not null)`
|
|
111
|
+
: sql`(${a} is not null and ${c} is null)`
|
|
112
|
+
const after = sql`(${beyond} or ${nulls})`
|
|
113
|
+
result = sql`(${after} or (${a} is not distinct from ${c} and ${result}))`
|
|
114
|
+
}
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A sort key: a column, through `one` relations, or a count; a row the
|
|
120
|
+
* relations do not reach gives `null` (D-134). A relevance is `ts_rank`'s
|
|
121
|
+
* (D-148).
|
|
122
|
+
*/
|
|
123
|
+
key(
|
|
124
|
+
table: Table,
|
|
125
|
+
alias: string,
|
|
126
|
+
order: Pick<Order, 'field' | 'through' | 'count' | 'relevance'>,
|
|
127
|
+
steps: NonNullable<Order['through']> = order.through ?? [],
|
|
128
|
+
): Sql {
|
|
129
|
+
const [step, ...rest] = steps
|
|
130
|
+
if (order.relevance !== undefined) {
|
|
131
|
+
return sql`ts_rank(${sql.id(alias, '_search')}, ${this.filters.query(order.relevance)})`
|
|
132
|
+
}
|
|
133
|
+
if (step === undefined) {
|
|
134
|
+
if (order.count === undefined) {
|
|
135
|
+
return this.filters.column(table, alias, order.field)
|
|
136
|
+
}
|
|
137
|
+
return this.count(table, alias, order.field, order.count)
|
|
138
|
+
}
|
|
139
|
+
const field = table.fields.get(step.relation)
|
|
140
|
+
if (field === undefined || field.kind === 'scalar') {
|
|
141
|
+
throw new Error(`${table.model.name}.${step.relation} is not a relation.`)
|
|
142
|
+
}
|
|
143
|
+
const to = this.filters.alias()
|
|
144
|
+
const reach = this.filters.reach(table, alias, field, to)
|
|
145
|
+
const where = this.filters.where(field.target, to, step.where)
|
|
146
|
+
const key = this.key(this.catalog.table(field.target), to, order, rest)
|
|
147
|
+
return sql`(select ${key} from ${reach.from} where ${reach.on} and ${where} limit 1)`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The rows of a `many` relation of the row that `where` selects. */
|
|
151
|
+
private count(table: Table, alias: string, name: string, where: Filter): Sql {
|
|
152
|
+
const field = table.fields.get(name)
|
|
153
|
+
if (field === undefined || field.kind === 'scalar') {
|
|
154
|
+
throw new Error(`${table.model.name}.${name} is not a relation.`)
|
|
155
|
+
}
|
|
156
|
+
const to = this.filters.alias()
|
|
157
|
+
const reach = this.filters.reach(table, alias, field, to)
|
|
158
|
+
const rows = this.filters.where(field.target, to, where)
|
|
159
|
+
return sql`(select cast(count(*) as integer) from ${reach.from} where ${reach.on} and ${rows})`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A scalar column, a date in UTC whatever the session's time zone, a
|
|
164
|
+
* decimal as text, which jsonb would make a number (D-145).
|
|
165
|
+
*/
|
|
166
|
+
private scalar(table: Table, alias: string, name: string): Sql {
|
|
167
|
+
const column = this.filters.column(table, alias, name)
|
|
168
|
+
const kind = this.catalog.kind(table, name)
|
|
169
|
+
if (kind === 'decimal') return sql`cast(${column} as text)`
|
|
170
|
+
return kind === 'dateTime' ? sql`(${column} at time zone 'UTC')` : column
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A row as jsonb: its fields, each `one` as `{ id }`, then its includes. */
|
|
174
|
+
row(model: string, alias: string, nested: Nested): Sql {
|
|
175
|
+
const table = this.catalog.table(model)
|
|
176
|
+
const pairs = new Map<string, Sql>()
|
|
177
|
+
const fields = nested.fields && new Set(['id', ...nested.fields])
|
|
178
|
+
const reads = (name: string) => fields?.has(name) ?? true
|
|
179
|
+
for (const name of ['id', 'createdAt', 'updatedAt'].filter(reads)) {
|
|
180
|
+
pairs.set(name, this.scalar(table, alias, name))
|
|
181
|
+
}
|
|
182
|
+
for (const field of table.model.fields) {
|
|
183
|
+
if (!reads(field.name)) continue
|
|
184
|
+
if (field.kind === 'scalar') {
|
|
185
|
+
pairs.set(field.name, this.scalar(table, alias, field.name))
|
|
186
|
+
} else if (field.kind === 'one' && field.stored) {
|
|
187
|
+
const key = this.filters.column(table, alias, field.name)
|
|
188
|
+
pairs.set(
|
|
189
|
+
field.name,
|
|
190
|
+
sql`case when ${key} is null then null else jsonb_build_object('id', ${key}) end`,
|
|
191
|
+
)
|
|
192
|
+
} else if (field.kind === 'one') {
|
|
193
|
+
const to = this.filters.alias()
|
|
194
|
+
const reach = this.filters.reach(table, alias, field, to)
|
|
195
|
+
pairs.set(
|
|
196
|
+
field.name,
|
|
197
|
+
sql`(select jsonb_build_object('id', ${sql.id(to, 'id')}) from ${reach.from} where ${reach.on} limit 1)`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const [name, inner] of Object.entries(nested.include ?? {})) {
|
|
202
|
+
const field = table.fields.get(name)
|
|
203
|
+
if (field === undefined || field.kind === 'scalar') continue
|
|
204
|
+
const to = this.filters.alias()
|
|
205
|
+
const reach = this.filters.reach(table, alias, field, to)
|
|
206
|
+
if (field.kind === 'many') {
|
|
207
|
+
pairs.set(name, this.list(field.target, inner, reach, to))
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
const where = this.filters.where(field.target, to, inner.where)
|
|
211
|
+
const row = this.row(field.target, to, inner)
|
|
212
|
+
pairs.set(
|
|
213
|
+
name,
|
|
214
|
+
sql`(select ${row} from ${reach.from} where ${reach.on} and ${where} limit 1)`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
const counts = Object.entries(nested.count ?? {}).map(
|
|
218
|
+
([name, where]) => [name, this.count(table, alias, name, where)] as const,
|
|
219
|
+
)
|
|
220
|
+
if (counts.length > 0) pairs.set('_count', object(counts))
|
|
221
|
+
return object([...pairs])
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The `id` of the first row of each `distinct` key in `read`'s order
|
|
227
|
+
* (D-147), which the read then sorts and pages.
|
|
228
|
+
*/
|
|
229
|
+
function kept(
|
|
230
|
+
compiler: Compiler,
|
|
231
|
+
read: Read,
|
|
232
|
+
distinct: readonly GroupKey[],
|
|
233
|
+
): Sql {
|
|
234
|
+
const { filters } = compiler
|
|
235
|
+
const table = compiler.catalog.table(read.model)
|
|
236
|
+
const alias = filters.alias()
|
|
237
|
+
const numbered = filters.alias()
|
|
238
|
+
filters.tables.add(table.name)
|
|
239
|
+
const keys = distinct.map((key) => compiler.key(table, alias, key))
|
|
240
|
+
const order = (read.orderBy ?? [{ field: 'id', direction: 'asc' }]).map(
|
|
241
|
+
(each) => sorted(compiler.key(table, alias, each), each),
|
|
242
|
+
)
|
|
243
|
+
const where = filters.where(read.model, alias, read.where)
|
|
244
|
+
const id = filters.column(table, alias, 'id')
|
|
245
|
+
const rows = sql`select row_number() over (partition by ${sql.join(keys)} order by ${sql.join(order)}) as "n", ${id} as "i" from ${sql.id(table.name)} as ${sql.id(alias)} where ${where}`
|
|
246
|
+
return sql`select ${sql.id(numbered, 'i')} from (${rows}) as ${sql.id(numbered)} where ${sql.id(numbered, 'n')} = 1`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `read`'s rows as one jsonb array, its root rows taken from `source`, by
|
|
251
|
+
* default its table, and the tables it reaches besides; a locked read locks
|
|
252
|
+
* the root rows its `where` selects, in `id` order (D-99, D-133).
|
|
253
|
+
*/
|
|
254
|
+
export function compileRows(
|
|
255
|
+
catalog: Catalog,
|
|
256
|
+
read: Read,
|
|
257
|
+
source?: Sql,
|
|
258
|
+
): { readonly rows: Sql; readonly tables: ReadonlySet<string> } {
|
|
259
|
+
const compiler = new Compiler(catalog)
|
|
260
|
+
const alias = compiler.filters.alias()
|
|
261
|
+
const name = source ?? sql.id(catalog.table(read.model).name)
|
|
262
|
+
const as = sql.id(alias)
|
|
263
|
+
const table = catalog.table(read.model)
|
|
264
|
+
const id = compiler.filters.column(table, alias, 'id')
|
|
265
|
+
// In `id` order, so two reads of the same rows wait instead of deadlocking (D-133).
|
|
266
|
+
let from = read.lock
|
|
267
|
+
? sql`(select * from ${name} as ${as} where ${compiler.filters.where(read.model, alias, read.where)} order by ${id} for update) as ${as}`
|
|
268
|
+
: sql`${name} as ${as}`
|
|
269
|
+
if (read.distinct !== undefined) {
|
|
270
|
+
from = sql`(select * from ${name} as ${as} where ${id} in (${kept(compiler, read, read.distinct)})) as ${as}`
|
|
271
|
+
}
|
|
272
|
+
const rows = compiler.list(read.model, read, { from, on: sql`true` }, alias)
|
|
273
|
+
return { rows, tables: compiler.filters.tables }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The statement that returns `read`'s rows as one jsonb array, `rows`. */
|
|
277
|
+
export function compileFind(catalog: Catalog, read: Read): Sql {
|
|
278
|
+
return sql`select ${compileRows(catalog, read).rows} as "rows"`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function compileCount(catalog: Catalog, read: CountRead): Sql {
|
|
282
|
+
const filters = new Filters(catalog)
|
|
283
|
+
const alias = filters.alias()
|
|
284
|
+
const table = catalog.table(read.model)
|
|
285
|
+
const where = filters.where(read.model, alias, read.where)
|
|
286
|
+
return sql`select cast(count(*) as integer) as "count" from ${sql.id(table.name)} as ${sql.id(alias)} where ${where}`
|
|
287
|
+
}
|
package/src/scratch.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// A test file's own schema (D-137): the connections' `search_path`, its
|
|
2
|
+
// tables emptied at each start, and dropped after the file's last test.
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import type { Kysely } from 'kysely'
|
|
5
|
+
import { sql } from 'kysely'
|
|
6
|
+
import type { PoolConfig } from 'pg'
|
|
7
|
+
import { Client } from 'pg'
|
|
8
|
+
import { parseIntoClientConfig } from 'pg-connection-string'
|
|
9
|
+
import { tables, tablesOf } from './migrations.ts'
|
|
10
|
+
import { id } from './plan-parts.ts'
|
|
11
|
+
|
|
12
|
+
/** A new test schema's name. */
|
|
13
|
+
export const scratchName = (): string =>
|
|
14
|
+
`ultimabase_test_${randomBytes(4).toString('hex')}`
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The connections to `url`, in `schema` when one is given, then in the
|
|
18
|
+
* schemas of the extensions its tables use (D-141).
|
|
19
|
+
*/
|
|
20
|
+
export function connecting(
|
|
21
|
+
url: string,
|
|
22
|
+
schema: string | undefined,
|
|
23
|
+
extensions: readonly string[] = [],
|
|
24
|
+
): PoolConfig {
|
|
25
|
+
if (schema === undefined) return { connectionString: url }
|
|
26
|
+
const config = parseIntoClientConfig(url)
|
|
27
|
+
// After the url's own options: a `search_path` it sets loses.
|
|
28
|
+
const path = [schema, ...extensions].join(',')
|
|
29
|
+
const options = [config.options, `-c search_path=${path}`]
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(' ')
|
|
32
|
+
return { ...config, options }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates `schema`, or empties the tables in it, once the connections are
|
|
37
|
+
* known to use it and nothing else.
|
|
38
|
+
*/
|
|
39
|
+
export async function emptied(
|
|
40
|
+
db: Kysely<object>,
|
|
41
|
+
schema: string,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
await sql`create schema if not exists ${sql.id(schema)}`.execute(db)
|
|
44
|
+
const { rows } = await sql<{
|
|
45
|
+
current: string | null
|
|
46
|
+
}>`select current_schema() as current`.execute(db)
|
|
47
|
+
if (rows[0]?.current !== schema) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`The connections did not take ${schema} as their search_path: does a pooler drop their startup options?`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
const names = (await tablesOf(db)).filter((name) => !tables.includes(name))
|
|
53
|
+
if (names.length === 0) return
|
|
54
|
+
await sql.raw(`truncate ${names.map(id).join(', ')} cascade`).execute(db)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Installs pg_trgm where a migration would, in the first schema of the
|
|
59
|
+
* url's own `search_path`, and answers that schema (D-141).
|
|
60
|
+
*/
|
|
61
|
+
export async function trigrams(url: string): Promise<string> {
|
|
62
|
+
const client = new Client({ connectionString: url })
|
|
63
|
+
await client.connect()
|
|
64
|
+
try {
|
|
65
|
+
await client
|
|
66
|
+
.query('create extension if not exists pg_trgm')
|
|
67
|
+
.catch((error: unknown) => {
|
|
68
|
+
// Another test file created it at the same time.
|
|
69
|
+
const code: unknown = Reflect.get(Object(error), 'code')
|
|
70
|
+
if (code !== '23505' && code !== '42710') throw error
|
|
71
|
+
})
|
|
72
|
+
const { rows } = await client.query<{ schema: string }>(
|
|
73
|
+
`select n.nspname as schema from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_trgm'`,
|
|
74
|
+
)
|
|
75
|
+
const [row] = rows
|
|
76
|
+
if (row === undefined) throw new Error('pg_trgm is not installed.')
|
|
77
|
+
return row.schema
|
|
78
|
+
} finally {
|
|
79
|
+
await client.end()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Drops `schema` and its tables. */
|
|
84
|
+
export async function dropped(url: string, schema: string): Promise<void> {
|
|
85
|
+
const client = new Client({ connectionString: url })
|
|
86
|
+
await client.connect()
|
|
87
|
+
try {
|
|
88
|
+
await client.query(`drop schema if exists ${id(schema)} cascade`)
|
|
89
|
+
} finally {
|
|
90
|
+
await client.end()
|
|
91
|
+
}
|
|
92
|
+
}
|