@owlmeans/postgres-resource 0.1.18-rc.4 → 0.1.18-rc.41
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 +185 -101
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/postgres-resource/SKILL.md +110 -21
- 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/errors.d.ts +3 -1
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +4 -2
- package/build/errors.js.map +1 -1
- package/build/resource.d.ts +2 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +112 -108
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +34 -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 +156 -11
- package/build/utils/criteria.js.map +1 -1
- package/build/utils/diff.d.ts +13 -0
- package/build/utils/diff.d.ts.map +1 -1
- package/build/utils/diff.js +60 -6
- package/build/utils/diff.js.map +1 -1
- package/build/utils/migrations.d.ts +2 -2
- package/build/utils/migrations.d.ts.map +1 -1
- package/build/utils/migrations.js +11 -8
- package/build/utils/migrations.js.map +1 -1
- 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/errors.ts +4 -2
- package/src/resource.ts +139 -135
- package/src/types.ts +36 -18
- package/src/utils/criteria.ts +202 -23
- package/src/utils/diff.ts +62 -7
- package/src/utils/migrations.ts +11 -8
- 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 +237 -0
- package/tests/diff.spec.ts +111 -0
- package/tests/errors.spec.ts +11 -0
- package/tests/name.spec.ts +3 -3
- package/tests/sql.spec.ts +4 -3
|
@@ -0,0 +1,237 @@
|
|
|
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, compared as typed JSON', () => {
|
|
123
|
+
const clause = where({ 'profile.city': 'Kyiv' })
|
|
124
|
+
|
|
125
|
+
/** The path is a bound text[] and the value a bound jsonb literal — neither is statement text. */
|
|
126
|
+
expect(clause.sql).toContain('#> $1::text[]')
|
|
127
|
+
expect(clause.sql).toContain('$2::jsonb')
|
|
128
|
+
expect(clause.params).toEqual([['city'], '"Kyiv"'])
|
|
129
|
+
|
|
130
|
+
/** A number or a boolean is compared as itself, never as its text. */
|
|
131
|
+
expect(where({ 'profile.age': 30 } as Criteria<Thing>).params).toEqual([['age'], '30'])
|
|
132
|
+
expect(where({ 'profile.verified': true } as Criteria<Thing>).params).toEqual([['verified'], 'true'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('reads a dotted array as membership, null as absence, and operators as they read on a column', () => {
|
|
136
|
+
const list = where({ 'profile.tier': ['gold', 'silver'] } as Criteria<Thing>)
|
|
137
|
+
expect(list.sql).toContain('= ANY(ARRAY[')
|
|
138
|
+
expect(list.sql).toContain(']::jsonb[])')
|
|
139
|
+
expect(list.params).toEqual([['tier'], '"gold"', '"silver"'])
|
|
140
|
+
|
|
141
|
+
const absent = where({ 'profile.tier': null } as Criteria<Thing>)
|
|
142
|
+
expect(absent.sql).toContain('IS NULL')
|
|
143
|
+
expect(absent.sql).toContain(`'null'::jsonb`)
|
|
144
|
+
|
|
145
|
+
const widened = where({ 'profile.tier': { $in: ['gold', null] } } as Criteria<Thing>)
|
|
146
|
+
expect(widened.sql).toContain('= ANY(ARRAY[')
|
|
147
|
+
expect(widened.sql).toContain('IS NULL')
|
|
148
|
+
|
|
149
|
+
expect(where({ 'profile.age': { $gte: 18 } } as Criteria<Thing>).sql).toContain('>= $2::jsonb')
|
|
150
|
+
expect(where({ 'profile.city': { $ilike: 'ky%' } } as Criteria<Thing>).sql).toContain('#>> $1::text[]) ILIKE')
|
|
151
|
+
expect(where({ 'profile.city': { $exists: true } } as Criteria<Thing>).sql).toMatch(/^NOT \(.* IS NULL OR .* = 'null'::jsonb\)$/)
|
|
152
|
+
expect(where({ 'profile.tags': { $contains: 'a' } } as Criteria<Thing>).params).toEqual([['tags'], '["a"]'])
|
|
153
|
+
/** A path segment holding a comma or a brace stays one segment. */
|
|
154
|
+
expect(where({ 'profile.a,b': 1 } as Criteria<Thing>).params).toEqual([['a,b'], '1'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('binds an array operand of a native array column as ONE array parameter, wrapping a scalar', () => {
|
|
158
|
+
const scalar = where({ tags: { $contains: 'rare' } })
|
|
159
|
+
expect(scalar.sql).toContain('"tags" @> $1::text[]')
|
|
160
|
+
expect(scalar.params).toEqual([['rare']])
|
|
161
|
+
|
|
162
|
+
const many = where({ tags: { $overlaps: ['rare', 'new'] } })
|
|
163
|
+
expect(many.sql).toContain('"tags" && $1::text[]')
|
|
164
|
+
expect(many.params).toEqual([['rare', 'new']])
|
|
165
|
+
|
|
166
|
+
expect(where({ tags: { $contained: ['rare', 'new'] } }).sql).toContain('<@ $1::text[]')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('reads jsonb array operators as JSON containment, and overlap element by element', () => {
|
|
170
|
+
const contains = where({ profile: { $contains: 'rare' } })
|
|
171
|
+
expect(contains.sql).toContain('"profile" @> $1::jsonb')
|
|
172
|
+
expect(contains.params).toEqual(['["rare"]'])
|
|
173
|
+
|
|
174
|
+
const overlaps = where({ profile: { $overlaps: ['rare', 2] } })
|
|
175
|
+
expect(overlaps.sql).toContain('jsonb_array_elements')
|
|
176
|
+
expect(overlaps.params).toEqual(['"rare"', '2', '"rare"', '2'])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('matches a jsonb column against a whole object with containment', () => {
|
|
180
|
+
const bare = where({ profile: { city: 'Kyiv' } })
|
|
181
|
+
expect(bare.sql).toContain('@>')
|
|
182
|
+
/** A jsonb operand is stringified on the way out; the driver would send an object as text. */
|
|
183
|
+
expect(bare.params).toEqual(['{"city":"Kyiv"}'])
|
|
184
|
+
|
|
185
|
+
expect(where({ profile: { $contains: { city: 'Kyiv' } } }).sql).toContain('@>')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
/** An unset filter must not empty the list it filters. */
|
|
189
|
+
test('skips an undefined value and answers nothing at all for an empty criteria', () => {
|
|
190
|
+
expect(criteriaToSql({ status: undefined }, spec, table)).toBeUndefined()
|
|
191
|
+
expect(criteriaToSql({}, spec, table)).toBeUndefined()
|
|
192
|
+
expect(criteriaToSql(undefined, spec, table)).toBeUndefined()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/** A typo silently widening a query to the whole table is worth being loud about. */
|
|
196
|
+
test('refuses an unknown key, an unknown operator and a path into a plain column', () => {
|
|
197
|
+
expect(() => criteriaToSql({ nope: 'x' } as unknown as Criteria<Thing>, spec, table))
|
|
198
|
+
.toThrow(UnsupportedArgumentError)
|
|
199
|
+
expect(() => criteriaToSql({ status: { $near: 'x' } } as unknown as Criteria<Thing>, spec, table))
|
|
200
|
+
.toThrow(UnsupportedArgumentError)
|
|
201
|
+
expect(() => criteriaToSql({ 'email.local': 'a' }, spec, table))
|
|
202
|
+
.toThrow(UnsupportedArgumentError)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('@owlmeans/postgres-resource — sortToSql', () => {
|
|
207
|
+
test('sorts a bare field name ascending', () => {
|
|
208
|
+
expect(order(['email'])).toContain('"email_address" asc')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('reverses on order: desc', () => {
|
|
212
|
+
expect(order([{ field: 'email', order: 'desc' }])).toContain('"email_address" desc')
|
|
213
|
+
/** `{ field }` alone means the same as the bare name. */
|
|
214
|
+
expect(order([{ field: 'email' }])).toContain('"email_address" asc')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Postgres has no implicit row order, so paginating on a non-unique key would silently
|
|
219
|
+
* duplicate and skip rows between pages.
|
|
220
|
+
*/
|
|
221
|
+
test('appends the primary key as a tiebreak, exactly once', () => {
|
|
222
|
+
const byStatus = order(['status'])
|
|
223
|
+
expect(byStatus).toContain('"status" asc')
|
|
224
|
+
expect(byStatus.split('"id" asc')).toHaveLength(2)
|
|
225
|
+
|
|
226
|
+
expect(order()).toContain('"id" asc')
|
|
227
|
+
/** Naming the key itself must not order by it twice. */
|
|
228
|
+
expect(order(['id']).split('"id" asc')).toHaveLength(2)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('refuses a field the table does not have', () => {
|
|
232
|
+
expect(() => sortToSql(['nope'] as unknown as Sort<Thing>[], spec, table))
|
|
233
|
+
.toThrow(UnsupportedArgumentError)
|
|
234
|
+
/** ORDER BY cannot reach into jsonb the way a criteria path can. */
|
|
235
|
+
expect(() => sortToSql(['profile.city'], spec, table)).toThrow(UnsupportedArgumentError)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { canonicalDefinition, 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
|
+
})
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What Postgres reports (`pg_indexes.indexdef`, `pg_get_constraintdef`) for objects created from the
|
|
64
|
+
* statements the plan emits — captured from a live server. Each pair must compare equal, or every
|
|
65
|
+
* boot drops and recreates the object.
|
|
66
|
+
*/
|
|
67
|
+
const RENDERED: Array<[string, string]> = [
|
|
68
|
+
['CREATE INDEX "t_a" ON "app"."t" USING btree ("garden", "season")', 'CREATE INDEX t_a ON app.t USING btree (garden, season)'],
|
|
69
|
+
['CREATE UNIQUE INDEX "t_b" ON "app"."t" USING btree ("garden", COALESCE("bed", \'\'), "season")',
|
|
70
|
+
'CREATE UNIQUE INDEX t_b ON app.t USING btree (garden, COALESCE(bed, \'\'::text), season)'],
|
|
71
|
+
['CREATE INDEX "t_c" ON "app"."t" USING btree ("garden", "crop") WHERE "crop" IS NOT NULL',
|
|
72
|
+
'CREATE INDEX t_c ON app.t USING btree (garden, crop) WHERE (crop IS NOT NULL)'],
|
|
73
|
+
['CREATE INDEX "t_d" ON "app"."t" USING btree ("garden") WHERE ("soil"->>\'state\') = \'wet\'',
|
|
74
|
+
'CREATE INDEX t_d ON app.t USING btree (garden) WHERE ((soil ->> \'state\'::text) = \'wet\'::text)'],
|
|
75
|
+
['CREATE INDEX "t_f" ON "app"."t" USING btree ("entityId", "crop") WHERE "kind" = \'a\'',
|
|
76
|
+
'CREATE INDEX t_f ON app.t USING btree ("entityId", crop) WHERE (kind = \'a\'::text)'],
|
|
77
|
+
['CREATE INDEX "t_g" ON "app"."t" USING btree (lower("crop"))', 'CREATE INDEX t_g ON app.t USING btree (lower((crop)::text))'],
|
|
78
|
+
['CHECK (("season" IN (\'spring\', \'summer\')))', 'CHECK ((season = ANY (ARRAY[\'spring\'::text, \'summer\'::text])))'],
|
|
79
|
+
['CHECK (("kind" IS NULL OR "kind" IN (\'a\', \'b\')))', 'CHECK (((kind IS NULL) OR (kind = ANY (ARRAY[\'a\'::text, \'b\'::text]))))'],
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
describe('@owlmeans/postgres-resource — comparing a declared default with the live one', () => {
|
|
83
|
+
test('a varchar id\'s generated default, stored with its casts, is no drift', () => {
|
|
84
|
+
const spec = schemaToTableSpec('shelves', {
|
|
85
|
+
type: 'object', properties: { id: { type: 'string', maxLength: 128 } }, required: ['id'],
|
|
86
|
+
} as never, 'app', 'shelf', true)
|
|
87
|
+
const live = liveOf([columnOf({ name: 'id', type: 'character varying(128)', defaultExpr: '(gen_random_uuid())::character varying' })])
|
|
88
|
+
|
|
89
|
+
expect(planSync(spec, live).statements).toEqual([])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('@owlmeans/postgres-resource — comparing a declared definition with the live one', () => {
|
|
94
|
+
test('a definition Postgres re-rendered compares equal to the statement that created it', () => {
|
|
95
|
+
for (const [declared, live] of RENDERED) {
|
|
96
|
+
expect([declared, canonicalDefinition(declared)]).toEqual([declared, canonicalDefinition(live)])
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('a real change still differs: columns, uniqueness, method and predicate', () => {
|
|
101
|
+
const base = 'CREATE INDEX t_a ON app.t USING btree (garden, season)'
|
|
102
|
+
for (const changed of [
|
|
103
|
+
'CREATE INDEX t_a ON app.t USING btree (season, garden)',
|
|
104
|
+
'CREATE UNIQUE INDEX t_a ON app.t USING btree (garden, season)',
|
|
105
|
+
'CREATE INDEX t_a ON app.t USING gin (garden, season)',
|
|
106
|
+
'CREATE INDEX t_a ON app.t USING btree (garden, season) WHERE (season IS NOT NULL)',
|
|
107
|
+
]) {
|
|
108
|
+
expect(canonicalDefinition(changed)).not.toBe(canonicalDefinition(base))
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
})
|
package/tests/errors.spec.ts
CHANGED
|
@@ -97,6 +97,17 @@ describe('@owlmeans/postgres-resource — driver error translation', () => {
|
|
|
97
97
|
expect(pgErrorToResourceError(original)).toBe(original)
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
test('preserves the executed SQL and driver cause for migration diagnostics', () => {
|
|
101
|
+
const original = driver({ code: '42703', message: 'column o.enquiryId does not exist' })
|
|
102
|
+
const query = 'UPDATE "app"."opportunity" o SET "enquiryId" = $1 WHERE o."enquiryId" IS NULL'
|
|
103
|
+
const failure = pgErrorToResourceError(original, query)
|
|
104
|
+
|
|
105
|
+
expect(failure).toBeInstanceOf(PostgresError)
|
|
106
|
+
expect(failure.message).toContain('42703')
|
|
107
|
+
expect(failure.message).toContain(`SQL: ${query}`)
|
|
108
|
+
expect(failure.cause).toBe(original)
|
|
109
|
+
})
|
|
110
|
+
|
|
100
111
|
test('leaves an error that is not from the driver alone', () => {
|
|
101
112
|
const plain = new Error('not postgres at all')
|
|
102
113
|
|
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') })
|