@owlmeans/postgres-resource 0.1.15
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/LICENSE +21 -0
- package/README.md +219 -0
- package/agent-meta/instructions/postgres-resource.instructions.md +60 -0
- package/agent-meta/manifest.json +23 -0
- package/agent-meta/skills/postgres-resource/SKILL.md +186 -0
- package/build/consts.d.ts +81 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +86 -0
- package/build/consts.js.map +1 -0
- package/build/declarations.d.ts +12 -0
- package/build/declarations.d.ts.map +1 -0
- package/build/declarations.js +28 -0
- package/build/declarations.js.map +1 -0
- package/build/errors.d.ts +70 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +201 -0
- package/build/errors.js.map +1 -0
- package/build/helper.d.ts +4 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +5 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/resource.d.ts +4 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +395 -0
- package/build/resource.js.map +1 -0
- package/build/types.d.ts +273 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/criteria.d.ts +24 -0
- package/build/utils/criteria.d.ts.map +1 -0
- package/build/utils/criteria.js +209 -0
- package/build/utils/criteria.js.map +1 -0
- package/build/utils/diff.d.ts +24 -0
- package/build/utils/diff.d.ts.map +1 -0
- package/build/utils/diff.js +330 -0
- package/build/utils/diff.js.map +1 -0
- package/build/utils/index.d.ts +12 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +12 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/introspect.d.ts +7 -0
- package/build/utils/introspect.d.ts.map +1 -0
- package/build/utils/introspect.js +70 -0
- package/build/utils/introspect.js.map +1 -0
- package/build/utils/life-cycle.d.ts +39 -0
- package/build/utils/life-cycle.d.ts.map +1 -0
- package/build/utils/life-cycle.js +119 -0
- package/build/utils/life-cycle.js.map +1 -0
- package/build/utils/marshal.d.ts +36 -0
- package/build/utils/marshal.d.ts.map +1 -0
- package/build/utils/marshal.js +152 -0
- package/build/utils/marshal.js.map +1 -0
- package/build/utils/migrations.d.ts +16 -0
- package/build/utils/migrations.d.ts.map +1 -0
- package/build/utils/migrations.js +108 -0
- package/build/utils/migrations.js.map +1 -0
- package/build/utils/name.d.ts +39 -0
- package/build/utils/name.d.ts.map +1 -0
- package/build/utils/name.js +63 -0
- package/build/utils/name.js.map +1 -0
- package/build/utils/schema.d.ts +28 -0
- package/build/utils/schema.d.ts.map +1 -0
- package/build/utils/schema.js +356 -0
- package/build/utils/schema.js.map +1 -0
- package/build/utils/sql.d.ts +22 -0
- package/build/utils/sql.d.ts.map +1 -0
- package/build/utils/sql.js +0 -0
- package/build/utils/sql.js.map +1 -0
- package/build/utils/sync.d.ts +21 -0
- package/build/utils/sync.d.ts.map +1 -0
- package/build/utils/sync.js +93 -0
- package/build/utils/sync.js.map +1 -0
- package/build/utils/table.d.ts +8 -0
- package/build/utils/table.d.ts.map +1 -0
- package/build/utils/table.js +68 -0
- package/build/utils/table.js.map +1 -0
- package/package.json +50 -0
- package/src/consts.ts +95 -0
- package/src/declarations.ts +41 -0
- package/src/errors.ts +242 -0
- package/src/helper.ts +7 -0
- package/src/index.ts +7 -0
- package/src/resource.ts +524 -0
- package/src/types.ts +310 -0
- package/src/utils/criteria.ts +246 -0
- package/src/utils/diff.ts +363 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/introspect.ts +87 -0
- package/src/utils/life-cycle.ts +155 -0
- package/src/utils/marshal.ts +175 -0
- package/src/utils/migrations.ts +135 -0
- package/src/utils/name.ts +79 -0
- package/src/utils/schema.ts +426 -0
- package/src/utils/sql.ts +0 -0
- package/src/utils/sync.ts +106 -0
- package/src/utils/table.ts +76 -0
- package/tests/errors.spec.ts +121 -0
- package/tests/name.spec.ts +75 -0
- package/tests/schema.spec.ts +258 -0
- package/tests/sql.spec.ts +104 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { MisshapedRecord, RecordExists, ResourceError } from '@owlmeans/resource'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
describePgError, PgErrorCode, PostgresCastRequired, PostgresCheckError, PostgresConnectionError,
|
|
6
|
+
PostgresConstraintError, PostgresDeadlockError, PostgresError, PostgresForeignKeyError,
|
|
7
|
+
pgErrorToResourceError
|
|
8
|
+
} from '@owlmeans/postgres-resource'
|
|
9
|
+
|
|
10
|
+
interface DriverShape {
|
|
11
|
+
code: string
|
|
12
|
+
message?: string
|
|
13
|
+
constraint?: string
|
|
14
|
+
table?: string
|
|
15
|
+
column?: string
|
|
16
|
+
detail?: string
|
|
17
|
+
hint?: string
|
|
18
|
+
severity?: string
|
|
19
|
+
where?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const driver = (shape: DriverShape): Error => Object.assign(new Error(shape.message ?? 'pg'), shape)
|
|
23
|
+
|
|
24
|
+
/** What Drizzle raises: its own class, with the driver error hung off `cause`. */
|
|
25
|
+
const wrapped = (shape: DriverShape): Error => {
|
|
26
|
+
const failure = new Error(`Failed query: insert into "app"."users" ...`)
|
|
27
|
+
failure.cause = driver(shape)
|
|
28
|
+
failure.name = 'DrizzleQueryError'
|
|
29
|
+
|
|
30
|
+
return failure
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('@owlmeans/postgres-resource — driver error translation', () => {
|
|
34
|
+
test('maps every code the resource layer promises a class for', () => {
|
|
35
|
+
const cases: Array<[string, Function]> = [
|
|
36
|
+
[PgErrorCode.UniqueViolation, RecordExists],
|
|
37
|
+
[PgErrorCode.NotNullViolation, MisshapedRecord],
|
|
38
|
+
[PgErrorCode.ForeignKeyViolation, PostgresForeignKeyError],
|
|
39
|
+
[PgErrorCode.CheckViolation, PostgresCheckError],
|
|
40
|
+
[PgErrorCode.DatatypeMismatch, PostgresCastRequired],
|
|
41
|
+
[PgErrorCode.CannotCoerce, PostgresCastRequired],
|
|
42
|
+
[PgErrorCode.DeadlockDetected, PostgresDeadlockError]
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for (const [code, expected] of cases) {
|
|
46
|
+
expect(pgErrorToResourceError(driver({ code }))).toBeInstanceOf(expected as never)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Class prefixes, for the codes with no dedicated class. */
|
|
50
|
+
expect(pgErrorToResourceError(driver({ code: '08006' }))).toBeInstanceOf(PostgresConnectionError)
|
|
51
|
+
expect(pgErrorToResourceError(driver({ code: '23P01' }))).toBeInstanceOf(PostgresConstraintError)
|
|
52
|
+
expect(pgErrorToResourceError(driver({ code: '42P01' }))).toBeInstanceOf(PostgresError)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* CRUD goes through Drizzle, so the driver error always arrives wrapped. Classifying on
|
|
57
|
+
* the outer error collapses the whole vocabulary above into one opaque class on exactly
|
|
58
|
+
* the paths callers use most.
|
|
59
|
+
*/
|
|
60
|
+
test('classifies a driver error Drizzle has wrapped', () => {
|
|
61
|
+
const failure = pgErrorToResourceError(wrapped({
|
|
62
|
+
code: PgErrorCode.UniqueViolation, constraint: 'users_email_key'
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
expect(failure).toBeInstanceOf(RecordExists)
|
|
66
|
+
expect(failure.message).toContain(PgErrorCode.UniqueViolation)
|
|
67
|
+
expect(failure.message).toContain('users_email_key')
|
|
68
|
+
/** The wrapper is kept as the cause — unwrapping is for classification, not for loss. */
|
|
69
|
+
expect((failure.cause as Error).name).toBe('DrizzleQueryError')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('leads with the code and never echoes bound parameters', () => {
|
|
73
|
+
const described = describePgError(wrapped({
|
|
74
|
+
code: PgErrorCode.UniqueViolation,
|
|
75
|
+
message: 'duplicate key value violates unique constraint "users_email_key"',
|
|
76
|
+
constraint: 'users_email_key',
|
|
77
|
+
table: 'users',
|
|
78
|
+
column: 'email',
|
|
79
|
+
detail: 'Key (email)=(a@b.c) already exists.',
|
|
80
|
+
hint: 'try another',
|
|
81
|
+
severity: 'ERROR',
|
|
82
|
+
where: `SQL statement "INSERT INTO users VALUES ('secret')"`
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
/** The fixer classifies on this text, so the code has to be the first thing in it. */
|
|
86
|
+
expect(described.startsWith(PgErrorCode.UniqueViolation)).toBe(true)
|
|
87
|
+
expect(described).toContain('table=users')
|
|
88
|
+
expect(described).toContain('column=email')
|
|
89
|
+
expect(described).toContain('hint=try another')
|
|
90
|
+
/** `where` can quote the statement, and the statement can quote a value. */
|
|
91
|
+
expect(described).not.toContain('SQL statement')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('passes a framework error through untouched', () => {
|
|
95
|
+
const original = new ResourceError('already-translated')
|
|
96
|
+
|
|
97
|
+
expect(pgErrorToResourceError(original)).toBe(original)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('leaves an error that is not from the driver alone', () => {
|
|
101
|
+
const plain = new Error('not postgres at all')
|
|
102
|
+
|
|
103
|
+
expect(pgErrorToResourceError(plain)).toBe(plain)
|
|
104
|
+
expect(describePgError(plain)).toBe('not postgres at all')
|
|
105
|
+
expect(pgErrorToResourceError('a string')).toBeInstanceOf(PostgresError)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('survives a circular cause chain', () => {
|
|
109
|
+
const outer = new Error('outer') as Error & { cause?: unknown }
|
|
110
|
+
const inner = new Error('inner') as Error & { cause?: unknown }
|
|
111
|
+
outer.cause = inner
|
|
112
|
+
inner.cause = outer
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returning at all is the assertion — an unbounded walk would spin forever. Note that
|
|
116
|
+
* `expect(fn).not.toThrow()` cannot say this in Bun: it reports a *returned* `Error` as
|
|
117
|
+
* a thrown one, and every one of these translations returns exactly that.
|
|
118
|
+
*/
|
|
119
|
+
expect(pgErrorToResourceError(outer)).toBe(outer)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
advisoryKey, assertSqlIdentifier, pgIdentifier, pgTableName, qualify, quoteIdent, quoteLiteral
|
|
6
|
+
} from '@owlmeans/postgres-resource'
|
|
7
|
+
import type { PostgresResource } from '@owlmeans/postgres-resource'
|
|
8
|
+
|
|
9
|
+
const resource = (alias: string, name?: string): PostgresResource<ResourceRecord> =>
|
|
10
|
+
({ alias, ...(name != null ? { name } : {}) }) as PostgresResource<ResourceRecord>
|
|
11
|
+
|
|
12
|
+
describe('@owlmeans/postgres-resource — identifiers', () => {
|
|
13
|
+
test('sanitizes an arbitrary name into something Postgres accepts', () => {
|
|
14
|
+
expect(pgIdentifier('users')).toBe('users')
|
|
15
|
+
/** Resource aliases carry `-` and `:`; column and table names cannot. */
|
|
16
|
+
expect(pgIdentifier('wl-profile')).toBe('wl_profile')
|
|
17
|
+
expect(pgIdentifier('auth:token')).toBe('auth_token')
|
|
18
|
+
expect(pgIdentifier('9lives')).toBe('_9lives')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Postgres truncates past `NAMEDATALEN - 1` server side and says nothing. Truncating here
|
|
23
|
+
* instead keeps the hash that made the name unique — the shared `dbName()` overflow path
|
|
24
|
+
* appends its disambiguator past byte 63, where the server would cut it off.
|
|
25
|
+
*/
|
|
26
|
+
test('keeps an overlong name unique inside the 63 byte limit', () => {
|
|
27
|
+
const long = 'a'.repeat(200)
|
|
28
|
+
const identifier = pgIdentifier(long)
|
|
29
|
+
|
|
30
|
+
expect(identifier.length).toBe(63)
|
|
31
|
+
expect(Buffer.byteLength(identifier, 'utf8')).toBeLessThanOrEqual(63)
|
|
32
|
+
expect(identifier).not.toBe(pgIdentifier(`${long}b`))
|
|
33
|
+
expect(identifier).toBe(pgIdentifier(long))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('assertSqlIdentifier is the last thing between a config value and injection', () => {
|
|
37
|
+
expect(assertSqlIdentifier('app_role')).toBe('app_role')
|
|
38
|
+
expect(() => assertSqlIdentifier('app"; DROP DATABASE x; --')).toThrow(SyntaxError)
|
|
39
|
+
expect(() => assertSqlIdentifier('has space')).toThrow(SyntaxError)
|
|
40
|
+
expect(() => assertSqlIdentifier('a'.repeat(64))).toThrow(SyntaxError)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('quotes identifiers and literals', () => {
|
|
44
|
+
expect(quoteIdent('users')).toBe('"users"')
|
|
45
|
+
expect(quoteIdent('we"ird')).toBe('"we""ird"')
|
|
46
|
+
expect(qualify('app', 'users')).toBe('"app"."users"')
|
|
47
|
+
|
|
48
|
+
expect(quoteLiteral("O'Brien")).toBe("'O''Brien'")
|
|
49
|
+
/** No escaping makes a NUL byte safe, so it is refused instead of encoded. */
|
|
50
|
+
expect(() => quoteLiteral('nul\0byte')).toThrow(SyntaxError)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('derives the physical table name from the resource and its prefix', () => {
|
|
54
|
+
const config = { service: 'postgres', host: 'localhost' } as DbConfig
|
|
55
|
+
|
|
56
|
+
expect(pgTableName(config, resource('wl-profile'))).toBe('wl_profile')
|
|
57
|
+
/** An explicit `name` wins — the alias may be unusable as an identifier. */
|
|
58
|
+
expect(pgTableName(config, resource('wl-profile', 'profiles'))).toBe('profiles')
|
|
59
|
+
expect(pgTableName({ ...config, resourcePrefix: 'app_' }, resource('users'))).toBe('app_users')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('advisory keys are a stable function of the qualified table', () => {
|
|
63
|
+
const key = advisoryKey('"app"."users"')
|
|
64
|
+
|
|
65
|
+
expect(key).toHaveLength(2)
|
|
66
|
+
expect(key).toEqual(advisoryKey('"app"."users"'))
|
|
67
|
+
expect(key).not.toEqual(advisoryKey('"app"."posts"'))
|
|
68
|
+
/** `pg_advisory_lock(int, int)` — anything outside int32 would be rejected by the server. */
|
|
69
|
+
for (const half of key) {
|
|
70
|
+
expect(Number.isInteger(half)).toBe(true)
|
|
71
|
+
expect(half).toBeGreaterThanOrEqual(-2147483648)
|
|
72
|
+
expect(half).toBeLessThanOrEqual(2147483647)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { UnsupportedArgumentError } from '@owlmeans/resource'
|
|
3
|
+
import type { AnySchema } from 'ajv'
|
|
4
|
+
|
|
5
|
+
import { schemaToTableSpec, toFormatType } from '@owlmeans/postgres-resource'
|
|
6
|
+
import type { TableSpec } from '@owlmeans/postgres-resource'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiling an AJV schema into a table specification is the one step every other part of
|
|
10
|
+
* the package trusts: DDL emission, drift detection, marshalling and placeholder
|
|
11
|
+
* resolution all read the result rather than the schema. It needs no database, so it is
|
|
12
|
+
* covered here rather than behind the integration gate.
|
|
13
|
+
*/
|
|
14
|
+
const compile = (schema: AnySchema, table: string = 'things'): TableSpec =>
|
|
15
|
+
schemaToTableSpec('things', schema, 'app', table, true)
|
|
16
|
+
|
|
17
|
+
describe('@owlmeans/postgres-resource — schemaToTableSpec', () => {
|
|
18
|
+
test('normalizes type spellings to what format_type() reports', () => {
|
|
19
|
+
expect(toFormatType('varchar(320)')).toBe('character varying(320)')
|
|
20
|
+
expect(toFormatType('int4')).toBe('integer')
|
|
21
|
+
expect(toFormatType('int8')).toBe('bigint')
|
|
22
|
+
expect(toFormatType('timestamptz')).toBe('timestamp with time zone')
|
|
23
|
+
expect(toFormatType('float8')).toBe('double precision')
|
|
24
|
+
expect(toFormatType('NUMERIC( 12 , 2 )')).toBe('numeric(12,2)')
|
|
25
|
+
expect(toFormatType('text[]')).toBe('text[]')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('synthesizes the id primary key when the schema does not declare one', () => {
|
|
29
|
+
const spec = compile({ type: 'object', properties: { name: { type: 'string' } } } as AnySchema)
|
|
30
|
+
|
|
31
|
+
expect(spec.primaryKey).toEqual(['id'])
|
|
32
|
+
const id = spec.byProperty.id
|
|
33
|
+
expect(id.sqlType).toBe('text')
|
|
34
|
+
expect(id.notNull).toBe(true)
|
|
35
|
+
expect(id.defaultRaw).toBe('gen_random_uuid()::text')
|
|
36
|
+
/** First, so `SELECT *` and every diff read it where a hand written table would put it. */
|
|
37
|
+
expect(spec.columns[0].property).toBe('id')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('promotes a declared uuid id to the primary key with a server side default', () => {
|
|
41
|
+
const spec = compile({
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: { id: { type: 'string', format: 'uuid' }, name: { type: 'string' } },
|
|
44
|
+
required: ['id', 'name']
|
|
45
|
+
} as AnySchema)
|
|
46
|
+
|
|
47
|
+
expect(spec.primaryKey).toEqual(['id'])
|
|
48
|
+
expect(spec.byProperty.id.sqlType).toBe('uuid')
|
|
49
|
+
expect(spec.byProperty.id.defaultRaw).toBe('gen_random_uuid()')
|
|
50
|
+
expect(spec.qualified).toBe('"app"."things"')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('maps JSON types onto Postgres types', () => {
|
|
54
|
+
const spec = compile({
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
plain: { type: 'string' },
|
|
58
|
+
bounded: { type: 'string', maxLength: 64 },
|
|
59
|
+
uuid: { type: 'string', format: 'uuid' },
|
|
60
|
+
stamp: { type: 'string', format: 'date-time' },
|
|
61
|
+
day: { type: 'string', format: 'date' },
|
|
62
|
+
blob: { type: 'string', format: 'binary' },
|
|
63
|
+
small: { type: 'integer' },
|
|
64
|
+
big: { type: 'integer', maximum: 9007199254740991 },
|
|
65
|
+
real: { type: 'number' },
|
|
66
|
+
money: { type: 'number', pg: { precision: 12, scale: 2 } },
|
|
67
|
+
flag: { type: 'boolean' },
|
|
68
|
+
payload: { type: 'object' },
|
|
69
|
+
/** The framework's date convention — an `object` carrying `format: 'date-time'`. */
|
|
70
|
+
createdAt: { type: 'object', format: 'date-time' },
|
|
71
|
+
labels: { type: 'array', items: { type: 'string' } },
|
|
72
|
+
rows: { type: 'array', items: { type: 'object' } }
|
|
73
|
+
}
|
|
74
|
+
} as AnySchema)
|
|
75
|
+
|
|
76
|
+
const types = Object.fromEntries(spec.columns.map(column => [column.property, column.sqlType]))
|
|
77
|
+
expect(types).toMatchObject({
|
|
78
|
+
plain: 'text',
|
|
79
|
+
bounded: 'character varying(64)',
|
|
80
|
+
uuid: 'uuid',
|
|
81
|
+
stamp: 'timestamp with time zone',
|
|
82
|
+
day: 'date',
|
|
83
|
+
blob: 'bytea',
|
|
84
|
+
small: 'integer',
|
|
85
|
+
big: 'bigint',
|
|
86
|
+
real: 'double precision',
|
|
87
|
+
money: 'numeric(12,2)',
|
|
88
|
+
flag: 'boolean',
|
|
89
|
+
payload: 'jsonb',
|
|
90
|
+
createdAt: 'timestamp with time zone',
|
|
91
|
+
labels: 'text[]',
|
|
92
|
+
rows: 'jsonb'
|
|
93
|
+
})
|
|
94
|
+
expect(spec.byProperty.labels.array).toBe(true)
|
|
95
|
+
expect(spec.byProperty.rows.array).toBe(false)
|
|
96
|
+
expect(spec.byProperty.payload.jsonb).toBe(true)
|
|
97
|
+
expect(spec.byProperty.createdAt.jsonType).toBe('date')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Mongo's mapper lost nullability twice, both times on a branch that recomputed it
|
|
102
|
+
* instead of receiving it — `date-time` and optional objects. Those two shapes are
|
|
103
|
+
* asserted explicitly, not just the general rule.
|
|
104
|
+
*/
|
|
105
|
+
test('threads nullability through every branch', () => {
|
|
106
|
+
const spec = compile({
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
id: { type: 'string', format: 'uuid' },
|
|
110
|
+
needed: { type: 'string' },
|
|
111
|
+
optional: { type: 'string' },
|
|
112
|
+
nullableStamp: { type: 'object', format: 'date-time', nullable: true },
|
|
113
|
+
requiredStamp: { type: 'object', format: 'date-time' },
|
|
114
|
+
optionalObject: { type: 'object' },
|
|
115
|
+
unionNull: { type: ['string', 'null'] },
|
|
116
|
+
forcedNull: { type: 'string', pg: { nullable: true } },
|
|
117
|
+
forcedNotNull: { type: 'string', pg: { nullable: false } }
|
|
118
|
+
},
|
|
119
|
+
required: ['id', 'needed', 'requiredStamp', 'unionNull', 'forcedNull', 'forcedNotNull']
|
|
120
|
+
} as AnySchema)
|
|
121
|
+
|
|
122
|
+
const notNull = Object.fromEntries(spec.columns.map(column => [column.property, column.notNull]))
|
|
123
|
+
expect(notNull).toMatchObject({
|
|
124
|
+
id: true,
|
|
125
|
+
needed: true,
|
|
126
|
+
optional: false,
|
|
127
|
+
nullableStamp: false,
|
|
128
|
+
requiredStamp: true,
|
|
129
|
+
optionalObject: false,
|
|
130
|
+
unionNull: false,
|
|
131
|
+
forcedNull: false,
|
|
132
|
+
forcedNotNull: true
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('applies the pg override vocabulary', () => {
|
|
137
|
+
const spec = compile({
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
id: { type: 'string', format: 'uuid' },
|
|
141
|
+
email: { type: 'string', pg: { type: 'varchar', length: 320, unique: true } },
|
|
142
|
+
ownerId: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
format: 'uuid',
|
|
145
|
+
pg: { references: { resource: 'users', onDelete: 'cascade' }, index: true }
|
|
146
|
+
},
|
|
147
|
+
renamed: { type: 'string', pg: { column: 'renamed_to' } },
|
|
148
|
+
touched: { type: 'object', format: 'date-time', pg: { defaultRaw: 'now()' } },
|
|
149
|
+
legacy: { type: 'string', pg: { managed: false } },
|
|
150
|
+
score: { type: 'integer', pg: { check: '{{col}} >= 0' } }
|
|
151
|
+
},
|
|
152
|
+
required: ['id', 'email'],
|
|
153
|
+
pg: { indexes: [{ name: 'idx_things_email_owner', columns: ['email', 'ownerId'] }] }
|
|
154
|
+
} as AnySchema)
|
|
155
|
+
|
|
156
|
+
expect(spec.byProperty.email.sqlType).toBe('character varying(320)')
|
|
157
|
+
expect(spec.uniques).toEqual([{ columns: ['email'], name: 'things_email_key' }])
|
|
158
|
+
|
|
159
|
+
expect(spec.references).toHaveLength(1)
|
|
160
|
+
expect(spec.references[0]).toMatchObject({
|
|
161
|
+
property: 'ownerId', resource: 'users', column: 'id', onDelete: 'cascade',
|
|
162
|
+
name: 'things_ownerId_fkey'
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/** No implicit snake_casing anywhere — only an explicit `column` renames. */
|
|
166
|
+
expect(spec.byProperty.renamed.column).toBe('renamed_to')
|
|
167
|
+
expect(spec.byColumn.renamed_to.property).toBe('renamed')
|
|
168
|
+
expect(spec.byProperty.touched.defaultRaw).toBe('now()')
|
|
169
|
+
|
|
170
|
+
expect(spec.unmanaged).toContain('legacy')
|
|
171
|
+
expect(spec.checks.map(check => check.expression)).toContain('"score" >= 0')
|
|
172
|
+
|
|
173
|
+
const names = spec.indexes.map(index => index.name)
|
|
174
|
+
expect(names).toContain('idx_things_email_owner')
|
|
175
|
+
expect(names).toContain('things_ownerId_idx')
|
|
176
|
+
/** Index specs address properties; the compiled spec addresses physical columns. */
|
|
177
|
+
expect(spec.indexes.find(index => index.name === 'idx_things_email_owner')?.columns)
|
|
178
|
+
.toEqual(['email', 'ownerId'])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('turns a string enum into a CHECK that tolerates NULL when the column does', () => {
|
|
182
|
+
const spec = compile({
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
id: { type: 'string', format: 'uuid' },
|
|
186
|
+
status: { type: 'string', enum: ['active', 'banned'] },
|
|
187
|
+
mood: { type: 'string', enum: ['ok'], nullable: true }
|
|
188
|
+
},
|
|
189
|
+
required: ['id', 'status']
|
|
190
|
+
} as AnySchema)
|
|
191
|
+
|
|
192
|
+
const expressions = spec.checks.map(check => check.expression)
|
|
193
|
+
expect(expressions).toContain(`"status" IN ('active', 'banned')`)
|
|
194
|
+
expect(expressions).toContain(`"mood" IS NULL OR "mood" IN ('ok')`)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('flattens allOf composition the way the framework schemas are written', () => {
|
|
198
|
+
const spec = compile({
|
|
199
|
+
type: 'object',
|
|
200
|
+
allOf: [
|
|
201
|
+
{ type: 'object', properties: { id: { type: 'string', format: 'uuid' } }, required: ['id'] },
|
|
202
|
+
{ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
|
203
|
+
],
|
|
204
|
+
properties: { extra: { type: 'boolean' } }
|
|
205
|
+
} as AnySchema)
|
|
206
|
+
|
|
207
|
+
expect(Object.keys(spec.byProperty).sort()).toEqual(['extra', 'id', 'name'])
|
|
208
|
+
expect(spec.byProperty.name.notNull).toBe(true)
|
|
209
|
+
expect(spec.byProperty.extra.notNull).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('honors a composite primary key declared at the root', () => {
|
|
213
|
+
const spec = compile({
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
tenant: { type: 'string' },
|
|
217
|
+
key: { type: 'string' },
|
|
218
|
+
value: { type: 'string' }
|
|
219
|
+
},
|
|
220
|
+
pg: { primaryKey: ['tenant', 'key'] }
|
|
221
|
+
} as AnySchema)
|
|
222
|
+
|
|
223
|
+
expect(spec.primaryKey).toEqual(['tenant', 'key'])
|
|
224
|
+
expect(spec.byProperty.tenant.notNull).toBe(true)
|
|
225
|
+
expect(spec.byProperty.key.notNull).toBe(true)
|
|
226
|
+
/** No id is synthesized once the schema says what identity means. */
|
|
227
|
+
expect(spec.byProperty.id).toBeUndefined()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('stores a secure field as unbounded text and refuses to make it unique', () => {
|
|
231
|
+
const spec = compile({
|
|
232
|
+
type: 'object',
|
|
233
|
+
properties: { token: { type: 'string', maxLength: 32, secure: true } }
|
|
234
|
+
} as AnySchema)
|
|
235
|
+
|
|
236
|
+
expect(spec.byProperty.token.sqlType).toBe('text')
|
|
237
|
+
expect(spec.byProperty.token.secure).toBe(true)
|
|
238
|
+
|
|
239
|
+
expect(() => compile({
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: { token: { type: 'string', secure: true, pg: { unique: true } } }
|
|
242
|
+
} as AnySchema)).toThrow(UnsupportedArgumentError)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('rejects an index or key naming a property that does not exist', () => {
|
|
246
|
+
expect(() => compile({
|
|
247
|
+
type: 'object',
|
|
248
|
+
properties: { name: { type: 'string' } },
|
|
249
|
+
pg: { indexes: [{ columns: ['nope'] }] }
|
|
250
|
+
} as AnySchema)).toThrow(UnsupportedArgumentError)
|
|
251
|
+
|
|
252
|
+
expect(() => compile({
|
|
253
|
+
type: 'object',
|
|
254
|
+
properties: { name: { type: 'string' } },
|
|
255
|
+
pg: { primaryKey: ['nope'] }
|
|
256
|
+
} as AnySchema)).toThrow(UnsupportedArgumentError)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PostgresPlaceholderError, refOf, resolvePlaceholders, schemaToTableSpec
|
|
6
|
+
} from '@owlmeans/postgres-resource'
|
|
7
|
+
import type { TableSpec } from '@owlmeans/postgres-resource'
|
|
8
|
+
|
|
9
|
+
const specOf = (alias: string, schema: string, table: string): TableSpec =>
|
|
10
|
+
schemaToTableSpec(alias, {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
id: { type: 'string', format: 'uuid' },
|
|
14
|
+
email: { type: 'string', pg: { column: 'email_address' } }
|
|
15
|
+
},
|
|
16
|
+
required: ['id']
|
|
17
|
+
} as never, schema, table, true)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The placeholder resolver only ever reads `context.resource(alias).table`, so a literal
|
|
21
|
+
* stand-in is both sufficient and clearer than booting a context — and it lets the
|
|
22
|
+
* uninitialized-resource case be constructed at all, which a real context would race.
|
|
23
|
+
*/
|
|
24
|
+
const contextOf = (resources: Record<string, TableSpec | null>): BasicContext<any> => ({
|
|
25
|
+
resource: (alias: string) => {
|
|
26
|
+
if (!(alias in resources)) {
|
|
27
|
+
throw new Error(`unknown:${alias}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { table: resources[alias] }
|
|
31
|
+
}
|
|
32
|
+
} as unknown as BasicContext<any>)
|
|
33
|
+
|
|
34
|
+
const users = specOf('users', 'app', 'users')
|
|
35
|
+
const posts = specOf('posts', 'app', 'posts')
|
|
36
|
+
const context = contextOf({ users, posts })
|
|
37
|
+
|
|
38
|
+
describe('@owlmeans/postgres-resource — placeholder resolution', () => {
|
|
39
|
+
test('resolves the owning resource', () => {
|
|
40
|
+
expect(resolvePlaceholders('SELECT * FROM {{}}', context, posts))
|
|
41
|
+
.toBe('SELECT * FROM "app"."posts"')
|
|
42
|
+
expect(resolvePlaceholders('SELECT * FROM {{ self }}', context, posts))
|
|
43
|
+
.toBe('SELECT * FROM "app"."posts"')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('resolves another registered resource, its columns, its bare name and its schema', () => {
|
|
47
|
+
expect(resolvePlaceholders('SELECT * FROM {{users}}', context, posts))
|
|
48
|
+
.toBe('SELECT * FROM "app"."users"')
|
|
49
|
+
/** The physical column, not the property — a `pg: { column }` rename has to survive. */
|
|
50
|
+
expect(resolvePlaceholders('SELECT {{users.email}}', context, posts))
|
|
51
|
+
.toBe('SELECT "app"."users"."email_address"')
|
|
52
|
+
expect(resolvePlaceholders('ON CONSTRAINT {{#users}}', context, posts))
|
|
53
|
+
.toBe('ON CONSTRAINT "users"')
|
|
54
|
+
expect(resolvePlaceholders('SET search_path = {{$}}', context, posts))
|
|
55
|
+
.toBe('SET search_path = "app"')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('leaves bound parameters exactly as written', () => {
|
|
59
|
+
const text = `SELECT * FROM {{}} WHERE "id" = $1 AND "email" = $2 AND note = '{{not-a-placeholder'`
|
|
60
|
+
expect(resolvePlaceholders(text, context, posts))
|
|
61
|
+
.toBe(`SELECT * FROM "app"."posts" WHERE "id" = $1 AND "email" = $2 AND note = '{{not-a-placeholder'`)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('refuses an alias it cannot resolve rather than substituting blindly', () => {
|
|
65
|
+
expect(() => resolvePlaceholders('SELECT * FROM {{nope}}', context, posts))
|
|
66
|
+
.toThrow(PostgresPlaceholderError)
|
|
67
|
+
expect(() => resolvePlaceholders('SELECT {{users.nope}}', context, posts))
|
|
68
|
+
.toThrow(PostgresPlaceholderError)
|
|
69
|
+
/** Registered but not yet initialized: the table name genuinely isn't known. */
|
|
70
|
+
expect(() => resolvePlaceholders('SELECT * FROM {{late}}', contextOf({ late: null }), posts))
|
|
71
|
+
.toThrow(PostgresPlaceholderError)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('refuses a self reference in a query that has no owning resource', () => {
|
|
75
|
+
expect(() => resolvePlaceholders('SELECT * FROM {{}}', context, null))
|
|
76
|
+
.toThrow(PostgresPlaceholderError)
|
|
77
|
+
expect(() => resolvePlaceholders('SET search_path = {{$}}', context, null))
|
|
78
|
+
.toThrow(PostgresPlaceholderError)
|
|
79
|
+
/** Other aliases stay addressable — a service level query is scopeless, not blind. */
|
|
80
|
+
expect(resolvePlaceholders('SELECT * FROM {{users}}', context, null))
|
|
81
|
+
.toBe('SELECT * FROM "app"."users"')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The cache is keyed by context because a layer switch rebuilds every resource against a
|
|
86
|
+
* different Postgres schema. A service level query has no owning table to key on, so a
|
|
87
|
+
* process wide cache would hand the next tenant the previous one's table names.
|
|
88
|
+
*/
|
|
89
|
+
test('does not leak resolved table names between contexts', () => {
|
|
90
|
+
const tenantA = contextOf({ users: specOf('users', 'tenant_a', 'users') })
|
|
91
|
+
const tenantB = contextOf({ users: specOf('users', 'tenant_b', 'users') })
|
|
92
|
+
|
|
93
|
+
expect(resolvePlaceholders('SELECT * FROM {{users}}', tenantA, null))
|
|
94
|
+
.toBe('SELECT * FROM "tenant_a"."users"')
|
|
95
|
+
expect(resolvePlaceholders('SELECT * FROM {{users}}', tenantB, null))
|
|
96
|
+
.toBe('SELECT * FROM "tenant_b"."users"')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('refOf answers the same question without any SQL around it', () => {
|
|
100
|
+
expect(refOf(context, posts)).toBe('"app"."posts"')
|
|
101
|
+
expect(refOf(context, posts, 'users')).toBe('"app"."users"')
|
|
102
|
+
expect(() => refOf(context, null)).toThrow(PostgresPlaceholderError)
|
|
103
|
+
})
|
|
104
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": [
|
|
11
|
+
"./dist/**/*",
|
|
12
|
+
"./build/**/*",
|
|
13
|
+
"./tests/**/*",
|
|
14
|
+
"./*.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|