@owlmeans/postgres-resource 0.1.18-rc.2 → 0.1.18-rc.21
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/README.md +27 -12
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/postgres-resource/SKILL.md +82 -18
- package/build/consts.d.ts +8 -1
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +8 -1
- package/build/consts.js.map +1 -1
- package/build/declarations.js +5 -5
- package/build/resource.d.ts +2 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +95 -108
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +27 -19
- package/build/types.d.ts.map +1 -1
- package/build/utils/criteria.d.ts +10 -6
- package/build/utils/criteria.d.ts.map +1 -1
- package/build/utils/criteria.js +13 -5
- package/build/utils/criteria.js.map +1 -1
- package/build/utils/diff.d.ts.map +1 -1
- package/build/utils/diff.js +31 -1
- package/build/utils/diff.js.map +1 -1
- package/build/utils/migrations.d.ts +2 -2
- package/build/utils/migrations.js +2 -2
- package/build/utils/name.d.ts +3 -3
- package/build/utils/name.js +3 -3
- package/build/utils/sql.d.ts +10 -2
- package/build/utils/sql.d.ts.map +1 -1
- package/build/utils/sql.js.map +1 -1
- package/build/utils/table.d.ts +4 -0
- package/build/utils/table.d.ts.map +1 -1
- package/build/utils/table.js +4 -0
- package/build/utils/table.js.map +1 -1
- package/package.json +7 -7
- package/src/consts.ts +8 -1
- package/src/declarations.ts +5 -5
- package/src/resource.ts +118 -135
- package/src/types.ts +29 -18
- package/src/utils/criteria.ts +25 -17
- package/src/utils/diff.ts +31 -1
- package/src/utils/migrations.ts +2 -2
- package/src/utils/name.ts +3 -3
- package/src/utils/sql.ts +0 -0
- package/src/utils/table.ts +5 -1
- package/tests/criteria.spec.ts +187 -0
- package/tests/diff.spec.ts +60 -0
- package/tests/name.spec.ts +3 -3
- package/tests/sql.spec.ts +4 -3
package/src/utils/criteria.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UnsupportedArgumentError } from '@owlmeans/resource'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Criteria, FieldOperators, Sort } from '@owlmeans/resource'
|
|
3
3
|
import { and, asc, desc, or, sql } from 'drizzle-orm'
|
|
4
4
|
import type { SQL } from 'drizzle-orm'
|
|
5
5
|
|
|
@@ -48,7 +48,7 @@ const inList = (table: PgRuntimeTable, column: ColumnSpec, values: unknown[], ne
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const operators = (
|
|
51
|
-
table: PgRuntimeTable, column: ColumnSpec, spec:
|
|
51
|
+
table: PgRuntimeTable, column: ColumnSpec, spec: FieldOperators<any>
|
|
52
52
|
): SQL[] => {
|
|
53
53
|
const conditions: SQL[] = []
|
|
54
54
|
for (const [operator, operand] of Object.entries(spec)) {
|
|
@@ -116,28 +116,33 @@ const operators = (
|
|
|
116
116
|
|
|
117
117
|
const escapeLike = (value: string): string => value.replace(/[\\%_]/g, match => `\\${match}`)
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
/**
|
|
120
|
+
* An object naming at least one `$` key is a spec, not a value to compare against.
|
|
121
|
+
* A Date and an array are values even though both are objects.
|
|
122
|
+
*/
|
|
123
|
+
const isOperatorSpec = (value: unknown): value is FieldOperators<any> =>
|
|
120
124
|
value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
|
|
121
125
|
&& Object.keys(value).some(key => key.startsWith('$'))
|
|
122
126
|
|
|
123
127
|
/**
|
|
124
|
-
* Translate
|
|
128
|
+
* Translate a {@link Criteria} into a WHERE clause. `undefined` means "no constraint at
|
|
129
|
+
* all" — the caller decides whether that is a full scan or a refusal.
|
|
125
130
|
*
|
|
126
131
|
* An unknown key raises rather than being skipped: a typo silently widening a query to
|
|
127
132
|
* the whole table is the failure mode worth being loud about.
|
|
128
133
|
*
|
|
129
134
|
* @throws {UnsupportedArgumentError}
|
|
130
135
|
*/
|
|
131
|
-
export const criteriaToSql = (
|
|
132
|
-
criteria:
|
|
136
|
+
export const criteriaToSql = <T>(
|
|
137
|
+
criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
|
|
133
138
|
): SQL | undefined => {
|
|
134
139
|
const conditions = build(criteria, spec, table)
|
|
135
140
|
|
|
136
141
|
return conditions.length > 0 ? and(...conditions) : undefined
|
|
137
142
|
}
|
|
138
143
|
|
|
139
|
-
const build = (
|
|
140
|
-
criteria:
|
|
144
|
+
const build = <T>(
|
|
145
|
+
criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
|
|
141
146
|
): SQL[] => {
|
|
142
147
|
const conditions: SQL[] = []
|
|
143
148
|
for (const [key, raw] of Object.entries(criteria ?? {})) {
|
|
@@ -147,7 +152,7 @@ const build = (
|
|
|
147
152
|
|
|
148
153
|
if (key === '$and' || key === '$or') {
|
|
149
154
|
const parts = (Array.isArray(raw) ? raw : [raw])
|
|
150
|
-
.map(entry => and(...build(entry as
|
|
155
|
+
.map(entry => and(...build(entry as Criteria<T>, spec, table)))
|
|
151
156
|
.filter((entry): entry is SQL => entry != null)
|
|
152
157
|
if (parts.length > 0) {
|
|
153
158
|
const combined = key === '$and' ? and(...parts) : or(...parts)
|
|
@@ -158,7 +163,7 @@ const build = (
|
|
|
158
163
|
continue
|
|
159
164
|
}
|
|
160
165
|
if (key === '$not') {
|
|
161
|
-
const inner = and(...build(raw as
|
|
166
|
+
const inner = and(...build(raw as Criteria<T>, spec, table))
|
|
162
167
|
if (inner != null) {
|
|
163
168
|
conditions.push(sql`NOT (${inner})`)
|
|
164
169
|
}
|
|
@@ -184,7 +189,7 @@ const build = (
|
|
|
184
189
|
continue
|
|
185
190
|
}
|
|
186
191
|
if (isOperatorSpec(raw)) {
|
|
187
|
-
conditions.push(...operators(table, column, raw
|
|
192
|
+
conditions.push(...operators(table, column, raw))
|
|
188
193
|
continue
|
|
189
194
|
}
|
|
190
195
|
if (Array.isArray(raw)) {
|
|
@@ -206,24 +211,27 @@ const build = (
|
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
/**
|
|
209
|
-
* Translate
|
|
210
|
-
* `
|
|
214
|
+
* Translate a {@link Sort} list into ORDER BY. A bare field name is ascending;
|
|
215
|
+
* `{ field, order: 'desc' }` reverses it — the same meaning every backend gives it.
|
|
211
216
|
*
|
|
212
217
|
* The primary key is always appended as a tiebreak. Postgres has no implicit row order, so
|
|
213
218
|
* paginating on a non-unique sort key silently duplicates and skips rows between pages —
|
|
214
219
|
* a difference from mongo that would otherwise surface as a data bug rather than an error.
|
|
215
220
|
*
|
|
221
|
+
* A dotted path is a column this table does not have: criteria can reach into jsonb, ORDER BY
|
|
222
|
+
* cannot, and refusing is better than ordering by something the caller did not name.
|
|
223
|
+
*
|
|
216
224
|
* @throws {UnsupportedArgumentError}
|
|
217
225
|
*/
|
|
218
|
-
export const sortToSql = (
|
|
219
|
-
sort:
|
|
226
|
+
export const sortToSql = <T>(
|
|
227
|
+
sort: Sort<T>[] | undefined, spec: TableSpec, table: PgRuntimeTable
|
|
220
228
|
): SQL[] => {
|
|
221
229
|
const order: SQL[] = []
|
|
222
230
|
const seen: string[] = []
|
|
223
231
|
|
|
224
232
|
for (const entry of sort ?? []) {
|
|
225
|
-
|
|
226
|
-
const
|
|
233
|
+
const property = typeof entry === 'string' ? entry : entry.field
|
|
234
|
+
const descending = typeof entry !== 'string' && entry.order === 'desc'
|
|
227
235
|
const column = spec.byProperty[property]
|
|
228
236
|
if (column == null) {
|
|
229
237
|
throw new UnsupportedArgumentError(`sort:${property}`)
|
package/src/utils/diff.ts
CHANGED
|
@@ -194,7 +194,31 @@ export const planSync = (
|
|
|
194
194
|
continue
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* A retype drops the column's default first.
|
|
199
|
+
*
|
|
200
|
+
* Postgres refuses `ALTER COLUMN … TYPE` outright when the column carries a DEFAULT it cannot
|
|
201
|
+
* cast to the new type — `42804 default for column "x" cannot be cast automatically` — and it
|
|
202
|
+
* refuses it before looking at a single row, so the `USING` clause never gets a chance to
|
|
203
|
+
* help. The whole plan runs in one transaction, so that refusal aborts the entire
|
|
204
|
+
* reconciliation: the table keeps its old shape and the resource fails every boot with an
|
|
205
|
+
* error about a column it is trying to fix. The standard remedy is to take the default out of
|
|
206
|
+
* the way, retype, and put it back — which is what the default-drift block below does, from
|
|
207
|
+
* the spec rather than from whatever the column happened to be carrying.
|
|
208
|
+
*
|
|
209
|
+
* The `id` column is the one that meets this in practice: it is created with a
|
|
210
|
+
* `gen_random_uuid()::text` default, so any change to its declared type hits this path.
|
|
211
|
+
*/
|
|
212
|
+
let defaultDropped = false
|
|
197
213
|
if (!additive && existing.type !== column.sqlType) {
|
|
214
|
+
if (existing.defaultExpr != null) {
|
|
215
|
+
statements.push({
|
|
216
|
+
kind: 'set-default',
|
|
217
|
+
target: column.column,
|
|
218
|
+
sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
|
|
219
|
+
})
|
|
220
|
+
defaultDropped = true
|
|
221
|
+
}
|
|
198
222
|
const using = column.using ?? `${quoteIdent(column.column)}::${column.sqlType}`
|
|
199
223
|
statements.push({
|
|
200
224
|
kind: 'alter-type',
|
|
@@ -205,7 +229,13 @@ export const planSync = (
|
|
|
205
229
|
}
|
|
206
230
|
|
|
207
231
|
const expression = defaultExpression(column)
|
|
208
|
-
|
|
232
|
+
// Once the retype has dropped it, the default has to be re-stated even when it matches what
|
|
233
|
+
// the column used to carry: the comparison is against the pre-plan introspection, which stops
|
|
234
|
+
// describing the column the moment the plan drops its default.
|
|
235
|
+
const defaultDrifted = defaultDropped
|
|
236
|
+
? expression != null
|
|
237
|
+
: normalizeDefault(expression) !== normalizeDefault(existing.defaultExpr)
|
|
238
|
+
if (defaultDrifted) {
|
|
209
239
|
statements.push(expression != null
|
|
210
240
|
? {
|
|
211
241
|
kind: 'set-default',
|
package/src/utils/migrations.ts
CHANGED
|
@@ -47,8 +47,8 @@ export const makeTx = (
|
|
|
47
47
|
/**
|
|
48
48
|
* Migration ledger, one table per Postgres schema.
|
|
49
49
|
*
|
|
50
|
-
* It lives inside the resource's own schema, so
|
|
51
|
-
*
|
|
50
|
+
* It lives inside the resource's own schema, so every schema carries the ledger for the
|
|
51
|
+
* tables it holds — correct, because those tables are what the migrations changed.
|
|
52
52
|
*/
|
|
53
53
|
export const makeMigrationStore = (
|
|
54
54
|
client: PoolClient,
|
package/src/utils/name.ts
CHANGED
|
@@ -10,9 +10,9 @@ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/
|
|
|
10
10
|
/**
|
|
11
11
|
* Coerce an arbitrary name into a legal Postgres identifier of at most 63 bytes.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Postgres cuts anything past `NAMEDATALEN - 1` off server side and says nothing about
|
|
14
|
+
* it, so two names that differ only beyond byte 63 would collapse into one identifier.
|
|
15
|
+
* Clamping here instead leaves room for a hash suffix that keeps them apart.
|
|
16
16
|
*/
|
|
17
17
|
export const pgIdentifier = (name: string): string => {
|
|
18
18
|
const sanitized = name.replace(/[^A-Za-z0-9_$]/g, '_')
|
package/src/utils/sql.ts
CHANGED
|
Binary file
|
package/src/utils/table.ts
CHANGED
|
@@ -24,6 +24,10 @@ const pgRaw = customType<{ data: unknown, driverData: unknown, config: { sqlType
|
|
|
24
24
|
* Build the Drizzle table used for CRUD query construction. Structure DDL is emitted from
|
|
25
25
|
* the {@link TableSpec} directly — Drizzle exposes no runtime DDL generator (that lives in
|
|
26
26
|
* drizzle-kit, which would be a second owner of the same tables).
|
|
27
|
+
*
|
|
28
|
+
* The result is stated as a {@link PgRuntimeTable}: the columns exist, keyed by schema
|
|
29
|
+
* property name, but only this function knows which ones — Drizzle's own inference has a
|
|
30
|
+
* literal column map to work from, and here that map was assembled a few lines ago.
|
|
27
31
|
*/
|
|
28
32
|
export const specToTable = (spec: TableSpec): PgRuntimeTable => {
|
|
29
33
|
const namespace = pgSchema(spec.schema)
|
|
@@ -72,5 +76,5 @@ export const specToTable = (spec: TableSpec): PgRuntimeTable => {
|
|
|
72
76
|
.filter((column: unknown) => column != null) as [any, ...any[]]
|
|
73
77
|
})]
|
|
74
78
|
: [])
|
|
75
|
-
]) as PgRuntimeTable
|
|
79
|
+
]) as unknown as PgRuntimeTable
|
|
76
80
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { UnsupportedArgumentError } from '@owlmeans/resource'
|
|
3
|
+
import type { Criteria, Sort } from '@owlmeans/resource'
|
|
4
|
+
import type { AnySchema } from 'ajv'
|
|
5
|
+
import { sql } from 'drizzle-orm'
|
|
6
|
+
import type { SQL } from 'drizzle-orm'
|
|
7
|
+
import { PgDialect } from 'drizzle-orm/pg-core'
|
|
8
|
+
|
|
9
|
+
import { criteriaToSql, schemaToTableSpec, sortToSql, specToTable } from '@owlmeans/postgres-resource'
|
|
10
|
+
import type { PgRuntimeTable, TableSpec } from '@owlmeans/postgres-resource'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Criteria translation is where a typo becomes either an error or a whole-table scan, and
|
|
14
|
+
* where a value has to stay a bound parameter instead of becoming statement text. Neither
|
|
15
|
+
* question needs a database: the dialect renders a clause exactly as the driver would
|
|
16
|
+
* receive it, so these specs assert on that rendering rather than on query results.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface Thing {
|
|
20
|
+
id?: string
|
|
21
|
+
email: string
|
|
22
|
+
status?: string
|
|
23
|
+
age?: number
|
|
24
|
+
tags?: string[]
|
|
25
|
+
profile?: Record<string, unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `email` is renamed on purpose — a clause must name the physical column, never the property. */
|
|
29
|
+
const schema = {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
id: { type: 'string', format: 'uuid' },
|
|
33
|
+
email: { type: 'string', pg: { column: 'email_address' } },
|
|
34
|
+
status: { type: 'string', nullable: true },
|
|
35
|
+
age: { type: 'integer', nullable: true },
|
|
36
|
+
tags: { type: 'array', items: { type: 'string' }, nullable: true },
|
|
37
|
+
profile: { type: 'object', nullable: true }
|
|
38
|
+
},
|
|
39
|
+
required: ['id', 'email']
|
|
40
|
+
} as AnySchema
|
|
41
|
+
|
|
42
|
+
const spec: TableSpec = schemaToTableSpec('things', schema, 'app', 'things', true)
|
|
43
|
+
const table: PgRuntimeTable = specToTable(spec)
|
|
44
|
+
const dialect = new PgDialect()
|
|
45
|
+
|
|
46
|
+
const render = (statement: SQL): { sql: string, params: unknown[] } => {
|
|
47
|
+
const query = dialect.sqlToQuery(statement)
|
|
48
|
+
|
|
49
|
+
return { sql: query.sql, params: query.params }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const where = (criteria: Criteria<Thing>): { sql: string, params: unknown[] } => {
|
|
53
|
+
const clause = criteriaToSql(criteria, spec, table)
|
|
54
|
+
expect(clause).not.toBeUndefined()
|
|
55
|
+
|
|
56
|
+
return render(clause as SQL)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const order = (sort?: Sort<Thing>[]): string =>
|
|
60
|
+
render(sql.join(sortToSql(sort, spec, table), sql`, `)).sql
|
|
61
|
+
|
|
62
|
+
describe('@owlmeans/postgres-resource — criteriaToSql', () => {
|
|
63
|
+
test('reads a bare value as equality, against the physical column', () => {
|
|
64
|
+
const clause = where({ email: 'a@b.c' })
|
|
65
|
+
|
|
66
|
+
expect(clause.sql).toContain('"email_address"')
|
|
67
|
+
expect(clause.sql).toContain('=')
|
|
68
|
+
/** The value never reaches the statement text. */
|
|
69
|
+
expect(clause.sql).not.toContain('a@b.c')
|
|
70
|
+
expect(clause.params).toEqual(['a@b.c'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('reads a bare array as membership and null as absence', () => {
|
|
74
|
+
const list = where({ status: ['open', 'done'] })
|
|
75
|
+
expect(list.sql).toContain('IN')
|
|
76
|
+
expect(list.params).toEqual(['open', 'done'])
|
|
77
|
+
|
|
78
|
+
expect(where({ status: null }).sql).toContain('IS NULL')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** `IN` never matches NULL, so a list carrying one has to be widened explicitly. */
|
|
82
|
+
test('widens a membership list that carries a null', () => {
|
|
83
|
+
const clause = where({ status: { $in: ['open', null] } })
|
|
84
|
+
|
|
85
|
+
expect(clause.sql).toContain('IN')
|
|
86
|
+
expect(clause.sql).toContain('IS NULL')
|
|
87
|
+
expect(clause.params).toEqual(['open'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** Postgres rejects an empty `IN ()`, and an empty set matches nothing — or everything, negated. */
|
|
91
|
+
test('collapses a membership list Postgres would reject', () => {
|
|
92
|
+
expect(where({ status: { $in: [] } }).sql).toContain('FALSE')
|
|
93
|
+
expect(where({ status: { $nin: [] } }).sql).toContain('TRUE')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('translates the comparison and text operators', () => {
|
|
97
|
+
expect(where({ age: { $gte: 18 } }).params).toEqual([18])
|
|
98
|
+
expect(where({ age: { $between: [18, 65] } }).sql).toContain('BETWEEN')
|
|
99
|
+
expect(where({ email: { $ilike: '%@b.c' } }).sql).toContain('ILIKE')
|
|
100
|
+
expect(where({ email: { $regex: '^a' } }).sql).toContain('~')
|
|
101
|
+
expect(where({ status: { $exists: false } }).sql).toContain('IS NULL')
|
|
102
|
+
expect(where({ status: { $null: false } }).sql).toContain('IS NOT NULL')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/** A wildcard inside the operand is data, not a pattern the caller wrote. */
|
|
106
|
+
test('escapes the LIKE wildcards in startsWith and endsWith', () => {
|
|
107
|
+
expect(where({ email: { $startsWith: 'a_b' } }).params).toEqual(['a\\_b%'])
|
|
108
|
+
expect(where({ email: { $endsWith: '100%' } }).params).toEqual(['%100\\%'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('composes with $or and $not', () => {
|
|
112
|
+
const clause = where({
|
|
113
|
+
$or: [{ status: 'open' }, { age: { $lt: 18 } }],
|
|
114
|
+
$not: { email: 'a@b.c' }
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(clause.sql).toContain(' or ')
|
|
118
|
+
expect(clause.sql).toContain('NOT')
|
|
119
|
+
expect(clause.params).toEqual(['open', 18, 'a@b.c'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('reaches into a jsonb column through a dotted path', () => {
|
|
123
|
+
const clause = where({ 'profile.city': 'Kyiv' })
|
|
124
|
+
|
|
125
|
+
expect(clause.sql).toContain('#>>')
|
|
126
|
+
expect(clause.params).toEqual(['{city}', 'Kyiv'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('matches a jsonb column against a whole object with containment', () => {
|
|
130
|
+
const bare = where({ profile: { city: 'Kyiv' } })
|
|
131
|
+
expect(bare.sql).toContain('@>')
|
|
132
|
+
/** A jsonb operand is stringified on the way out; the driver would send an object as text. */
|
|
133
|
+
expect(bare.params).toEqual(['{"city":"Kyiv"}'])
|
|
134
|
+
|
|
135
|
+
expect(where({ profile: { $contains: { city: 'Kyiv' } } }).sql).toContain('@>')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/** An unset filter must not empty the list it filters. */
|
|
139
|
+
test('skips an undefined value and answers nothing at all for an empty criteria', () => {
|
|
140
|
+
expect(criteriaToSql({ status: undefined }, spec, table)).toBeUndefined()
|
|
141
|
+
expect(criteriaToSql({}, spec, table)).toBeUndefined()
|
|
142
|
+
expect(criteriaToSql(undefined, spec, table)).toBeUndefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/** A typo silently widening a query to the whole table is worth being loud about. */
|
|
146
|
+
test('refuses an unknown key, an unknown operator and a path into a plain column', () => {
|
|
147
|
+
expect(() => criteriaToSql({ nope: 'x' } as unknown as Criteria<Thing>, spec, table))
|
|
148
|
+
.toThrow(UnsupportedArgumentError)
|
|
149
|
+
expect(() => criteriaToSql({ status: { $near: 'x' } } as unknown as Criteria<Thing>, spec, table))
|
|
150
|
+
.toThrow(UnsupportedArgumentError)
|
|
151
|
+
expect(() => criteriaToSql({ 'email.local': 'a' }, spec, table))
|
|
152
|
+
.toThrow(UnsupportedArgumentError)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('@owlmeans/postgres-resource — sortToSql', () => {
|
|
157
|
+
test('sorts a bare field name ascending', () => {
|
|
158
|
+
expect(order(['email'])).toContain('"email_address" asc')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('reverses on order: desc', () => {
|
|
162
|
+
expect(order([{ field: 'email', order: 'desc' }])).toContain('"email_address" desc')
|
|
163
|
+
/** `{ field }` alone means the same as the bare name. */
|
|
164
|
+
expect(order([{ field: 'email' }])).toContain('"email_address" asc')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Postgres has no implicit row order, so paginating on a non-unique key would silently
|
|
169
|
+
* duplicate and skip rows between pages.
|
|
170
|
+
*/
|
|
171
|
+
test('appends the primary key as a tiebreak, exactly once', () => {
|
|
172
|
+
const byStatus = order(['status'])
|
|
173
|
+
expect(byStatus).toContain('"status" asc')
|
|
174
|
+
expect(byStatus.split('"id" asc')).toHaveLength(2)
|
|
175
|
+
|
|
176
|
+
expect(order()).toContain('"id" asc')
|
|
177
|
+
/** Naming the key itself must not order by it twice. */
|
|
178
|
+
expect(order(['id']).split('"id" asc')).toHaveLength(2)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('refuses a field the table does not have', () => {
|
|
182
|
+
expect(() => sortToSql(['nope'] as unknown as Sort<Thing>[], spec, table))
|
|
183
|
+
.toThrow(UnsupportedArgumentError)
|
|
184
|
+
/** ORDER BY cannot reach into jsonb the way a criteria path can. */
|
|
185
|
+
expect(() => sortToSql(['profile.city'], spec, table)).toThrow(UnsupportedArgumentError)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { planSync, schemaToTableSpec } from '@owlmeans/postgres-resource'
|
|
4
|
+
import type { LiveColumn, LiveTable, TableSpec } from '@owlmeans/postgres-resource'
|
|
5
|
+
|
|
6
|
+
const specOf = (idType: 'string' | 'integer'): TableSpec =>
|
|
7
|
+
schemaToTableSpec('tasks', {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
id: idType === 'string' ? { type: 'string' } : { type: 'integer' },
|
|
11
|
+
title: { type: 'string' },
|
|
12
|
+
},
|
|
13
|
+
required: ['id'],
|
|
14
|
+
} as never, 'app', 'task_entry', true)
|
|
15
|
+
|
|
16
|
+
const columnOf = (over: Partial<LiveColumn> & { name: string, type: string }): LiveColumn => ({
|
|
17
|
+
notNull: true, defaultExpr: null, identity: '', generated: '', ordinal: 1, ...over,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const liveOf = (columns: LiveColumn[]): LiveTable =>
|
|
21
|
+
({ exists: true, columns, indexes: [], constraints: [] })
|
|
22
|
+
|
|
23
|
+
const kinds = (spec: TableSpec, live: LiveTable): string[] =>
|
|
24
|
+
planSync(spec, live).statements
|
|
25
|
+
.filter(statement => statement.target === 'id')
|
|
26
|
+
.map(statement => `${statement.kind}:${statement.sql.includes('DROP DEFAULT') ? 'drop' : statement.sql.includes('SET DEFAULT') ? 'set' : ''}`)
|
|
27
|
+
|
|
28
|
+
describe('@owlmeans/postgres-resource — retyping a column that carries a default', () => {
|
|
29
|
+
test('drops the default before the type change and restores it after', () => {
|
|
30
|
+
// Postgres refuses `ALTER COLUMN … TYPE` while the column has a DEFAULT it cannot cast, and
|
|
31
|
+
// the whole plan is one transaction — so without the drop the entire reconciliation aborts and
|
|
32
|
+
// the resource fails every boot.
|
|
33
|
+
const live = liveOf([
|
|
34
|
+
columnOf({ name: 'id', type: 'integer', defaultExpr: `gen_random_uuid()::text` }),
|
|
35
|
+
columnOf({ name: 'title', type: 'text', notNull: false }),
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
expect(kinds(specOf('string'), live)).toEqual(['set-default:drop', 'alter-type:', 'set-default:set'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a retype of a column without a default emits no default statements', () => {
|
|
42
|
+
const live = liveOf([
|
|
43
|
+
columnOf({ name: 'id', type: 'integer', defaultExpr: null }),
|
|
44
|
+
columnOf({ name: 'title', type: 'text', notNull: false }),
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
expect(kinds(specOf('string'), live)).toEqual(['alter-type:', 'set-default:set'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a column already on the right type keeps its default untouched', () => {
|
|
51
|
+
const spec = specOf('string')
|
|
52
|
+
const desired = spec.columns.find(column => column.column === 'id')!
|
|
53
|
+
const live = liveOf([
|
|
54
|
+
columnOf({ name: 'id', type: desired.sqlType, defaultExpr: desired.defaultRaw ?? null }),
|
|
55
|
+
columnOf({ name: 'title', type: 'text', notNull: false }),
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
expect(kinds(spec, live)).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
})
|
package/tests/name.spec.ts
CHANGED
|
@@ -19,9 +19,9 @@ describe('@owlmeans/postgres-resource — identifiers', () => {
|
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Postgres truncates past `NAMEDATALEN - 1` server side and says nothing
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Postgres truncates past `NAMEDATALEN - 1` server side and says nothing, which would let
|
|
23
|
+
* two names differing only beyond byte 63 collapse into one identifier. Clamping here
|
|
24
|
+
* instead keeps the hash suffix that tells them apart inside the limit.
|
|
25
25
|
*/
|
|
26
26
|
test('keeps an overlong name unique inside the 63 byte limit', () => {
|
|
27
27
|
const long = 'a'.repeat(200)
|
package/tests/sql.spec.ts
CHANGED
|
@@ -82,9 +82,10 @@ describe('@owlmeans/postgres-resource — placeholder resolution', () => {
|
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* The cache is keyed by context because a
|
|
86
|
-
* different
|
|
87
|
-
*
|
|
85
|
+
* The cache is keyed by context because a context is what owns a set of database handles:
|
|
86
|
+
* a different context resolves the same alias against a different Postgres schema. A
|
|
87
|
+
* service level query has no owning table to key on, so a process wide cache would hand
|
|
88
|
+
* one context the other's table names.
|
|
88
89
|
*/
|
|
89
90
|
test('does not leak resolved table names between contexts', () => {
|
|
90
91
|
const tenantA = contextOf({ users: specOf('users', 'tenant_a', 'users') })
|