@avelonjs/postgres 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/LICENSE +21 -0
- package/README.md +197 -0
- package/package.json +51 -0
- package/src/compile.ts +177 -0
- package/src/driver.ts +386 -0
- package/src/errors.ts +100 -0
- package/src/fixtures.ts +59 -0
- package/src/index.ts +19 -0
- package/src/migrations.ts +123 -0
- package/src/normalize.ts +50 -0
- package/src/schema.ts +123 -0
- package/src/validate.ts +236 -0
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Predicate, QueryIR } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
/** Normalizes a predicate using the contract's empty-list and constant identities. */
|
|
4
|
+
export function normalizePredicate(predicate: Predicate): Predicate {
|
|
5
|
+
switch (predicate.kind) {
|
|
6
|
+
case 'const':
|
|
7
|
+
case 'compare':
|
|
8
|
+
case 'null':
|
|
9
|
+
return predicate
|
|
10
|
+
case 'in':
|
|
11
|
+
return predicate.values.length === 0 ? { kind: 'const', value: predicate.negated } : predicate
|
|
12
|
+
case 'not': {
|
|
13
|
+
const child = normalizePredicate(predicate.predicate)
|
|
14
|
+
if (child.kind === 'const') return { kind: 'const', value: !child.value }
|
|
15
|
+
if (child.kind === 'not') return normalizePredicate(child.predicate)
|
|
16
|
+
return { kind: 'not', predicate: child }
|
|
17
|
+
}
|
|
18
|
+
case 'and':
|
|
19
|
+
return normalizeGroup('and', predicate.predicates)
|
|
20
|
+
case 'or':
|
|
21
|
+
return normalizeGroup('or', predicate.predicates)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeGroup(kind: 'and' | 'or', input: readonly Predicate[]): Predicate {
|
|
26
|
+
const identity = kind === 'and'
|
|
27
|
+
const predicates: Predicate[] = []
|
|
28
|
+
|
|
29
|
+
for (const raw of input) {
|
|
30
|
+
const predicate = normalizePredicate(raw)
|
|
31
|
+
if (predicate.kind === 'const') {
|
|
32
|
+
if (predicate.value !== identity) return predicate
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (predicate.kind === kind) predicates.push(...predicate.predicates)
|
|
36
|
+
else predicates.push(predicate)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (predicates.length === 0) return { kind: 'const', value: identity }
|
|
40
|
+
if (predicates.length === 1) return predicates[0] as Predicate
|
|
41
|
+
return { kind, predicates }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Combines `where` and optional `ward` with AND, then normalizes. */
|
|
45
|
+
export function combinedPredicate(ir: Pick<QueryIR, 'where' | 'ward'>): Predicate {
|
|
46
|
+
return normalizePredicate({
|
|
47
|
+
kind: 'and',
|
|
48
|
+
predicates: ir.ward === undefined ? ir.where : [...ir.where, ir.ward],
|
|
49
|
+
})
|
|
50
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { SQL } from 'bun'
|
|
2
|
+
import { Invalid, type QueryIR, type RelationLoad } from '@avelonjs/core'
|
|
3
|
+
import { mapPostgresError } from './errors'
|
|
4
|
+
|
|
5
|
+
/** Cached public-schema column sets keyed by table name. */
|
|
6
|
+
export type SchemaCache = Map<string, Set<string>>
|
|
7
|
+
|
|
8
|
+
/** Loads every public base table and its columns. */
|
|
9
|
+
export async function loadSchemaCache(sql: SQL): Promise<SchemaCache> {
|
|
10
|
+
try {
|
|
11
|
+
const rows = (await sql.unsafe(`
|
|
12
|
+
SELECT table_name, column_name
|
|
13
|
+
FROM information_schema.columns
|
|
14
|
+
WHERE table_schema = 'public'
|
|
15
|
+
ORDER BY table_name, ordinal_position
|
|
16
|
+
`)) as Array<{ table_name: string; column_name: string }>
|
|
17
|
+
|
|
18
|
+
const cache: SchemaCache = new Map()
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
const columns = cache.get(row.table_name) ?? new Set<string>()
|
|
21
|
+
columns.add(row.column_name)
|
|
22
|
+
cache.set(row.table_name, columns)
|
|
23
|
+
}
|
|
24
|
+
return cache
|
|
25
|
+
} catch (error) {
|
|
26
|
+
mapPostgresError(error, 'schema.load')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function invalid(message: string, field: string, detail: string): never {
|
|
31
|
+
throw new Invalid(message, { metadata: { fields: { [field]: [detail] } } })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertTable(cache: SchemaCache, table: string, field: string): Set<string> {
|
|
35
|
+
const columns = cache.get(table)
|
|
36
|
+
if (columns === undefined) {
|
|
37
|
+
invalid(`Unknown table ${table}.`, field, 'Unknown identifier')
|
|
38
|
+
}
|
|
39
|
+
return columns
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertColumn(columns: Set<string>, table: string, column: string, field: string): void {
|
|
43
|
+
if (!columns.has(column)) {
|
|
44
|
+
invalid(`Unknown column ${column} on ${table}.`, field, `Unknown identifier: ${column}`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertProjection(
|
|
49
|
+
columns: Set<string>,
|
|
50
|
+
table: string,
|
|
51
|
+
projection: string[] | '*',
|
|
52
|
+
field: string,
|
|
53
|
+
): void {
|
|
54
|
+
if (projection === '*') return
|
|
55
|
+
for (const column of projection) assertColumn(columns, table, column, field)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertRelation(cache: SchemaCache, parentTable: string, relation: RelationLoad): void {
|
|
59
|
+
const parentColumns = assertTable(cache, parentTable, 'relations')
|
|
60
|
+
assertColumn(parentColumns, parentTable, relation.localKey, 'relations')
|
|
61
|
+
const relatedColumns = assertTable(cache, relation.table, 'relations')
|
|
62
|
+
assertColumn(relatedColumns, relation.table, relation.foreignKey, 'relations')
|
|
63
|
+
assertProjection(relatedColumns, relation.table, relation.select, 'relations')
|
|
64
|
+
for (const term of relation.order) {
|
|
65
|
+
assertColumn(relatedColumns, relation.table, term.column, 'relations')
|
|
66
|
+
}
|
|
67
|
+
for (const nested of relation.relations) assertRelation(cache, relation.table, nested)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Rejects unknown tables and columns before a statement is sent.
|
|
72
|
+
*
|
|
73
|
+
* Needed so empty parent row sets cannot skip a relation table that does not exist.
|
|
74
|
+
*/
|
|
75
|
+
export function assertQueryAgainstSchema(cache: SchemaCache, query: QueryIR): void {
|
|
76
|
+
const columns = assertTable(cache, query.table, 'table')
|
|
77
|
+
assertProjection(columns, query.table, query.select, 'select')
|
|
78
|
+
if (query.returning !== undefined) {
|
|
79
|
+
assertProjection(columns, query.table, query.returning, 'returning')
|
|
80
|
+
}
|
|
81
|
+
for (const term of query.order) assertColumn(columns, query.table, term.column, 'order')
|
|
82
|
+
|
|
83
|
+
const checkPredicateColumns = (predicate: QueryIR['where'][number]): void => {
|
|
84
|
+
switch (predicate.kind) {
|
|
85
|
+
case 'compare':
|
|
86
|
+
case 'null':
|
|
87
|
+
case 'in':
|
|
88
|
+
assertColumn(columns, query.table, predicate.column, 'where')
|
|
89
|
+
return
|
|
90
|
+
case 'and':
|
|
91
|
+
case 'or':
|
|
92
|
+
predicate.predicates.forEach(checkPredicateColumns)
|
|
93
|
+
return
|
|
94
|
+
case 'not':
|
|
95
|
+
checkPredicateColumns(predicate.predicate)
|
|
96
|
+
return
|
|
97
|
+
case 'const':
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
query.where.forEach(checkPredicateColumns)
|
|
102
|
+
if (query.ward) checkPredicateColumns(query.ward)
|
|
103
|
+
|
|
104
|
+
if (query.values !== undefined) {
|
|
105
|
+
const rows = Array.isArray(query.values) ? query.values : [query.values]
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
for (const column of Object.keys(row)) assertColumn(columns, query.table, column, 'values')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (query.conflict !== undefined) {
|
|
112
|
+
for (const column of query.conflict.columns) {
|
|
113
|
+
assertColumn(columns, query.table, column, 'conflict')
|
|
114
|
+
}
|
|
115
|
+
if (query.conflict.update !== '*') {
|
|
116
|
+
for (const column of query.conflict.update) {
|
|
117
|
+
assertColumn(columns, query.table, column, 'conflict')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const relation of query.relations) assertRelation(cache, query.table, relation)
|
|
123
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Invalid, type OrderTerm, type Predicate, type QueryIR, type RelationLoad } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
4
|
+
const COMPARE_OPS = new Set(['=', '!=', '<', '<=', '>', '>=', 'like', 'ilike'] as const)
|
|
5
|
+
const MODES = new Set(['select', 'count', 'insert', 'update', 'delete', 'upsert'] as const)
|
|
6
|
+
const RELATION_KINDS = new Set(['belongsTo', 'hasOne', 'hasMany'] as const)
|
|
7
|
+
|
|
8
|
+
function invalid(message: string, field: string, detail: string): never {
|
|
9
|
+
throw new Invalid(message, { metadata: { fields: { [field]: [detail] } } })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Asserts a value is a simple SQL identifier safe to quote. */
|
|
17
|
+
export function assertIdentifier(value: unknown, path: string): asserts value is string {
|
|
18
|
+
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
|
|
19
|
+
invalid(`${path} must be a simple SQL identifier.`, path, 'Expected a simple identifier')
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertNonNegativeInteger(value: unknown, path: string): asserts value is number {
|
|
24
|
+
if (!Number.isInteger(value) || (value as number) < 0) {
|
|
25
|
+
invalid(`${path} must be a non-negative integer.`, path, 'Expected a non-negative integer')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateProjection(value: unknown, path: string): asserts value is string[] | '*' {
|
|
30
|
+
if (value === '*') return
|
|
31
|
+
// Empty arrays are valid for write/count IR where `select` is unused; select mode still projects
|
|
32
|
+
// through `*` or an explicit column list supplied by the caller.
|
|
33
|
+
if (!Array.isArray(value)) {
|
|
34
|
+
invalid(`${path} must be '*' or a string array.`, path, 'Invalid projection')
|
|
35
|
+
}
|
|
36
|
+
for (const [index, column] of value.entries()) assertIdentifier(column, `${path}[${index}]`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateOrder(value: unknown, path: string): asserts value is OrderTerm[] {
|
|
40
|
+
if (!Array.isArray(value)) invalid(`${path} must be an array.`, path, 'Expected an array')
|
|
41
|
+
for (const [index, term] of value.entries()) {
|
|
42
|
+
if (!isRecord(term)) invalid(`${path}[${index}] must be an object.`, path, 'Expected an object')
|
|
43
|
+
assertIdentifier(term.column, `${path}[${index}].column`)
|
|
44
|
+
if (term.direction !== 'asc' && term.direction !== 'desc') {
|
|
45
|
+
invalid(`${path}[${index}].direction is invalid.`, path, 'Expected asc or desc')
|
|
46
|
+
}
|
|
47
|
+
if (term.nulls !== undefined && term.nulls !== 'first' && term.nulls !== 'last') {
|
|
48
|
+
invalid(`${path}[${index}].nulls is invalid.`, path, 'Expected first or last')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validatePredicate(value: unknown, path: string): asserts value is Predicate {
|
|
54
|
+
if (!isRecord(value) || typeof value.kind !== 'string') {
|
|
55
|
+
invalid(`${path} must be a predicate.`, path, 'Expected a predicate')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
switch (value.kind) {
|
|
59
|
+
case 'const':
|
|
60
|
+
if (typeof value.value !== 'boolean') {
|
|
61
|
+
invalid(`${path}.value must be boolean.`, path, 'Expected boolean')
|
|
62
|
+
}
|
|
63
|
+
return
|
|
64
|
+
case 'compare':
|
|
65
|
+
assertIdentifier(value.column, `${path}.column`)
|
|
66
|
+
if (!COMPARE_OPS.has(value.op as (typeof COMPARE_OPS extends Set<infer T> ? T : never))) {
|
|
67
|
+
invalid(`${path}.op is invalid.`, path, 'Unknown comparison operator')
|
|
68
|
+
}
|
|
69
|
+
return
|
|
70
|
+
case 'null':
|
|
71
|
+
assertIdentifier(value.column, `${path}.column`)
|
|
72
|
+
if (typeof value.negated !== 'boolean') {
|
|
73
|
+
invalid(`${path}.negated must be boolean.`, path, 'Expected boolean')
|
|
74
|
+
}
|
|
75
|
+
return
|
|
76
|
+
case 'in':
|
|
77
|
+
assertIdentifier(value.column, `${path}.column`)
|
|
78
|
+
if (!Array.isArray(value.values)) {
|
|
79
|
+
invalid(`${path}.values must be an array.`, path, 'Expected an array')
|
|
80
|
+
}
|
|
81
|
+
if (typeof value.negated !== 'boolean') {
|
|
82
|
+
invalid(`${path}.negated must be boolean.`, path, 'Expected boolean')
|
|
83
|
+
}
|
|
84
|
+
return
|
|
85
|
+
case 'and':
|
|
86
|
+
case 'or':
|
|
87
|
+
if (!Array.isArray(value.predicates)) {
|
|
88
|
+
invalid(`${path}.predicates must be an array.`, path, 'Expected an array')
|
|
89
|
+
}
|
|
90
|
+
for (const [index, predicate] of value.predicates.entries()) {
|
|
91
|
+
validatePredicate(predicate, `${path}.predicates[${index}]`)
|
|
92
|
+
}
|
|
93
|
+
return
|
|
94
|
+
case 'not':
|
|
95
|
+
validatePredicate(value.predicate, `${path}.predicate`)
|
|
96
|
+
return
|
|
97
|
+
default:
|
|
98
|
+
invalid(`${path}.kind is invalid.`, path, 'Unknown predicate kind')
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validateRelation(value: unknown, path: string, depth: number, maxDepth: number): void {
|
|
103
|
+
if (!isRecord(value)) invalid(`${path} must be an object.`, path, 'Expected an object')
|
|
104
|
+
if (depth > maxDepth) {
|
|
105
|
+
invalid(
|
|
106
|
+
`${path} exceeds maxRelationDepth ${maxDepth}.`,
|
|
107
|
+
'relations',
|
|
108
|
+
`Maximum relation depth is ${maxDepth}`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
assertIdentifier(value.relation, `${path}.relation`)
|
|
112
|
+
if (!RELATION_KINDS.has(value.kind as RelationLoad['kind'])) {
|
|
113
|
+
invalid(`${path}.kind is invalid.`, path, 'Unknown relation kind')
|
|
114
|
+
}
|
|
115
|
+
assertIdentifier(value.table, `${path}.table`)
|
|
116
|
+
assertIdentifier(value.localKey, `${path}.localKey`)
|
|
117
|
+
assertIdentifier(value.foreignKey, `${path}.foreignKey`)
|
|
118
|
+
validateProjection(value.select, `${path}.select`)
|
|
119
|
+
if (value.where !== null) validatePredicate(value.where, `${path}.where`)
|
|
120
|
+
validateOrder(value.order, `${path}.order`)
|
|
121
|
+
if (value.limit !== undefined) assertNonNegativeInteger(value.limit, `${path}.limit`)
|
|
122
|
+
if (!Array.isArray(value.relations)) {
|
|
123
|
+
invalid(`${path}.relations must be an array.`, path, 'Expected an array')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const names = new Set<string>()
|
|
127
|
+
for (const [index, relation] of value.relations.entries()) {
|
|
128
|
+
validateRelation(relation, `${path}.relations[${index}]`, depth + 1, maxDepth)
|
|
129
|
+
const name = (relation as RelationLoad).relation
|
|
130
|
+
if (names.has(name)) {
|
|
131
|
+
invalid(`${path}.relations contains duplicate relation '${name}'.`, path, 'Duplicate relation')
|
|
132
|
+
}
|
|
133
|
+
names.add(name)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateValues(ir: QueryIR): void {
|
|
138
|
+
const rows =
|
|
139
|
+
ir.values === undefined ? undefined : Array.isArray(ir.values) ? ir.values : [ir.values]
|
|
140
|
+
|
|
141
|
+
if (ir.mode === 'insert' || ir.mode === 'upsert') {
|
|
142
|
+
if (rows === undefined || rows.length === 0) {
|
|
143
|
+
invalid(`${ir.mode} requires non-empty values.`, 'values', 'Expected one or more rows')
|
|
144
|
+
}
|
|
145
|
+
} else if (ir.mode === 'update') {
|
|
146
|
+
if (rows === undefined || rows.length !== 1 || Array.isArray(ir.values)) {
|
|
147
|
+
invalid('update requires one values object.', 'values', 'Expected one row')
|
|
148
|
+
}
|
|
149
|
+
} else if (ir.values !== undefined) {
|
|
150
|
+
invalid(`${ir.mode} does not accept values.`, 'values', 'Unexpected values')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (rows === undefined) return
|
|
154
|
+
const firstColumns = Object.keys(rows[0] ?? {})
|
|
155
|
+
if (firstColumns.length === 0) {
|
|
156
|
+
invalid(`${ir.mode} values must not be empty.`, 'values', 'Expected non-empty rows')
|
|
157
|
+
}
|
|
158
|
+
for (const [index, row] of rows.entries()) {
|
|
159
|
+
if (!isRecord(row)) invalid(`values[${index}] must be an object.`, 'values', 'Expected an object')
|
|
160
|
+
const columns = Object.keys(row)
|
|
161
|
+
if (
|
|
162
|
+
columns.length !== firstColumns.length ||
|
|
163
|
+
columns.some((column) => !firstColumns.includes(column))
|
|
164
|
+
) {
|
|
165
|
+
invalid('all values rows must contain the same columns.', 'values', 'Mismatched columns')
|
|
166
|
+
}
|
|
167
|
+
for (const column of columns) assertIdentifier(column, `values[${index}] column`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Validates a query IR against portable contract rules before compilation. */
|
|
172
|
+
export function validateQueryIR(ir: QueryIR, maxRelationDepth: number): void {
|
|
173
|
+
if (!isRecord(ir)) invalid('query must be an object.', 'query', 'Expected an object')
|
|
174
|
+
assertIdentifier(ir.table, 'table')
|
|
175
|
+
if (!MODES.has(ir.mode)) invalid('mode is invalid.', 'mode', 'Unknown query mode')
|
|
176
|
+
validateProjection(ir.select, 'select')
|
|
177
|
+
if (!Array.isArray(ir.where)) invalid('where must be an array.', 'where', 'Expected an array')
|
|
178
|
+
for (const [index, predicate] of ir.where.entries()) {
|
|
179
|
+
validatePredicate(predicate, `where[${index}]`)
|
|
180
|
+
}
|
|
181
|
+
if (ir.ward !== undefined) validatePredicate(ir.ward, 'ward')
|
|
182
|
+
validateOrder(ir.order, 'order')
|
|
183
|
+
if (ir.limit !== undefined) assertNonNegativeInteger(ir.limit, 'limit')
|
|
184
|
+
if (ir.offset !== undefined) assertNonNegativeInteger(ir.offset, 'offset')
|
|
185
|
+
if (!Array.isArray(ir.relations)) {
|
|
186
|
+
invalid('relations must be an array.', 'relations', 'Expected an array')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const relationNames = new Set<string>()
|
|
190
|
+
for (const [index, relation] of ir.relations.entries()) {
|
|
191
|
+
validateRelation(relation, `relations[${index}]`, 1, maxRelationDepth)
|
|
192
|
+
if (relationNames.has(relation.relation)) {
|
|
193
|
+
invalid(
|
|
194
|
+
`relations contains duplicate relation '${relation.relation}'.`,
|
|
195
|
+
'relations',
|
|
196
|
+
'Duplicate relation',
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
relationNames.add(relation.relation)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ir.relations.length > 0 && ir.mode !== 'select') {
|
|
203
|
+
invalid('relations are only valid for select.', 'relations', 'Select-only field')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (ir.mode === 'count') {
|
|
207
|
+
if (ir.select === '*' || (Array.isArray(ir.select) && ir.select.length > 0)) {
|
|
208
|
+
invalid('count does not accept a projection.', 'mode', 'Use an empty select')
|
|
209
|
+
}
|
|
210
|
+
if (ir.order.length > 0) invalid('count does not accept an order.', 'mode', 'Remove order')
|
|
211
|
+
if (ir.limit !== undefined) invalid('count does not accept a limit.', 'mode', 'Remove limit')
|
|
212
|
+
if (ir.offset !== undefined) invalid('count does not accept an offset.', 'mode', 'Remove offset')
|
|
213
|
+
if (ir.returning !== undefined) {
|
|
214
|
+
invalid('count does not accept returning.', 'mode', 'Remove returning')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
validateValues(ir)
|
|
219
|
+
|
|
220
|
+
if (ir.returning !== undefined) validateProjection(ir.returning, 'returning')
|
|
221
|
+
if (ir.mode === 'upsert') {
|
|
222
|
+
if (!isRecord(ir.conflict)) invalid('upsert requires conflict.', 'conflict', 'Required for upsert')
|
|
223
|
+
if (!Array.isArray(ir.conflict.columns) || ir.conflict.columns.length === 0) {
|
|
224
|
+
invalid('conflict.columns must be non-empty.', 'conflict', 'Expected one or more columns')
|
|
225
|
+
}
|
|
226
|
+
for (const column of ir.conflict.columns) assertIdentifier(column, 'conflict.columns entry')
|
|
227
|
+
if (ir.conflict.update !== '*') {
|
|
228
|
+
if (!Array.isArray(ir.conflict.update) || ir.conflict.update.length === 0) {
|
|
229
|
+
invalid("conflict.update must be '*' or a non-empty array.", 'conflict', 'Invalid update list')
|
|
230
|
+
}
|
|
231
|
+
for (const column of ir.conflict.update) assertIdentifier(column, 'conflict.update entry')
|
|
232
|
+
}
|
|
233
|
+
} else if (ir.conflict !== undefined) {
|
|
234
|
+
invalid('conflict is only valid for upsert.', 'conflict', 'Unexpected conflict')
|
|
235
|
+
}
|
|
236
|
+
}
|