@mikro-orm/sql 7.1.0-dev.2 → 7.1.0-dev.20
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/AbstractSqlConnection.d.ts +1 -1
- package/AbstractSqlConnection.js +2 -2
- package/AbstractSqlDriver.d.ts +25 -1
- package/AbstractSqlDriver.js +315 -15
- package/PivotCollectionPersister.js +13 -2
- package/SqlEntityManager.d.ts +5 -1
- package/SqlEntityManager.js +36 -1
- package/dialects/mssql/MsSqlNativeQueryBuilder.js +4 -0
- package/dialects/mysql/BaseMySqlPlatform.d.ts +1 -0
- package/dialects/mysql/BaseMySqlPlatform.js +3 -0
- package/dialects/mysql/MySqlNativeQueryBuilder.js +11 -0
- package/dialects/mysql/MySqlSchemaHelper.d.ts +9 -3
- package/dialects/mysql/MySqlSchemaHelper.js +102 -4
- package/dialects/oracledb/OracleDialect.d.ts +1 -1
- package/dialects/oracledb/OracleDialect.js +2 -1
- package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +1 -0
- package/dialects/postgresql/BasePostgreSqlPlatform.js +3 -0
- package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +21 -1
- package/dialects/postgresql/PostgreSqlSchemaHelper.js +200 -4
- package/dialects/sqlite/SqliteSchemaHelper.d.ts +6 -1
- package/dialects/sqlite/SqliteSchemaHelper.js +114 -2
- package/package.json +3 -3
- package/query/CriteriaNode.d.ts +1 -1
- package/query/CriteriaNode.js +2 -2
- package/query/NativeQueryBuilder.d.ts +6 -0
- package/query/NativeQueryBuilder.js +16 -1
- package/query/ObjectCriteriaNode.js +1 -1
- package/query/QueryBuilder.d.ts +77 -0
- package/query/QueryBuilder.js +170 -6
- package/schema/DatabaseSchema.js +18 -0
- package/schema/DatabaseTable.d.ts +13 -1
- package/schema/DatabaseTable.js +50 -3
- package/schema/SchemaComparator.d.ts +1 -0
- package/schema/SchemaComparator.js +86 -1
- package/schema/SchemaHelper.d.ts +51 -1
- package/schema/SchemaHelper.js +191 -2
- package/schema/SqlSchemaGenerator.js +7 -0
- package/schema/partitioning.d.ts +13 -0
- package/schema/partitioning.js +326 -0
- package/typings.d.ts +32 -1
package/schema/SchemaHelper.js
CHANGED
|
@@ -110,7 +110,7 @@ export class SchemaHelper {
|
|
|
110
110
|
// JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
|
|
111
111
|
sql = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${tableName}`;
|
|
112
112
|
const columns = this.platform.getJsonIndexDefinition(index);
|
|
113
|
-
return `${sql} (${columns.join(', ')})${this.getCreateIndexSuffix(index)}${defer}`;
|
|
113
|
+
return `${sql} (${columns.join(', ')})${this.getCreateIndexSuffix(index)}${this.getIndexWhereClause(index)}${defer}`;
|
|
114
114
|
}
|
|
115
115
|
// Build column list with advanced options
|
|
116
116
|
const columns = this.getIndexColumns(index);
|
|
@@ -119,7 +119,7 @@ export class SchemaHelper {
|
|
|
119
119
|
if (index.include?.length) {
|
|
120
120
|
sql += ` include (${index.include.map(c => this.quote(c)).join(', ')})`;
|
|
121
121
|
}
|
|
122
|
-
return sql + this.getCreateIndexSuffix(index) + defer;
|
|
122
|
+
return sql + this.getCreateIndexSuffix(index) + this.getIndexWhereClause(index) + defer;
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
125
|
* Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
|
|
@@ -127,6 +127,153 @@ export class SchemaHelper {
|
|
|
127
127
|
getCreateIndexSuffix(_index) {
|
|
128
128
|
return '';
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
|
|
132
|
+
* return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
|
|
133
|
+
* with its own `getCreateIndexSQL` that never calls this, and MariaDB refuses the feature
|
|
134
|
+
* entirely via an override on `getIndexColumns`.
|
|
135
|
+
*/
|
|
136
|
+
getIndexWhereClause(index) {
|
|
137
|
+
return index.where ? ` where ${index.where}` : '';
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Wraps each indexed column in `(CASE WHEN <predicate> THEN <col> END)` for dialects that
|
|
141
|
+
* emulate partial indexes via functional indexes (MySQL/MariaDB/Oracle). Combined with NULL
|
|
142
|
+
* being treated as distinct in unique indexes, this enforces uniqueness only where the
|
|
143
|
+
* predicate holds. Throws if combined with the advanced `columns` option.
|
|
144
|
+
*/
|
|
145
|
+
emulatePartialIndexColumns(index) {
|
|
146
|
+
if (index.columns?.length) {
|
|
147
|
+
throw new Error(`Index '${index.keyName}': combining \`where\` with advanced \`columns\` options is not supported when emulating a partial index via functional expressions; use plain \`properties\` (or \`columnNames\`).`);
|
|
148
|
+
}
|
|
149
|
+
const predicate = index.where;
|
|
150
|
+
return index.columnNames.map(c => `(case when ${predicate} then ${this.quote(c)} end)`).join(', ');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Strips `<col> IS NOT NULL` clauses (with the dialect's identifier quoting) from an
|
|
154
|
+
* introspected partial-index predicate when the column matches one of the index's own
|
|
155
|
+
* columns. MikroORM auto-emits this guard for unique indexes on nullable columns
|
|
156
|
+
* (MSSQL, Oracle) — it's an internal artifact, not user intent.
|
|
157
|
+
*
|
|
158
|
+
* Strips at most one guard per column (the tail-most occurrence), matching how MikroORM
|
|
159
|
+
* appends a single guard per index column. This preserves user intent when they redundantly
|
|
160
|
+
* include the same `<col> IS NOT NULL` in their predicate — the guard we added is removed,
|
|
161
|
+
* their copy survives.
|
|
162
|
+
*/
|
|
163
|
+
stripAutoNotNullFilter(filterDef, columnNames, identifierPattern) {
|
|
164
|
+
// Peel off any number of balanced wrapping paren layers. Introspection sources differ
|
|
165
|
+
// (MSSQL `filter_definition` wraps once, Oracle `INDEX_EXPRESSIONS` typically not at all),
|
|
166
|
+
// and a user `where` round-tripped through a dialect that double-wraps would otherwise slip
|
|
167
|
+
// past the auto-NOT-NULL recognizer below.
|
|
168
|
+
let inner = filterDef.trim();
|
|
169
|
+
while (inner.startsWith('(') && inner.endsWith(')') && this.isBalancedWrap(inner)) {
|
|
170
|
+
inner = inner.slice(1, -1).trim();
|
|
171
|
+
}
|
|
172
|
+
const clauses = this.splitTopLevelAnd(inner);
|
|
173
|
+
const autoCol = (clause) => {
|
|
174
|
+
let trimmed = clause.trim();
|
|
175
|
+
while (trimmed.startsWith('(') && trimmed.endsWith(')') && this.isBalancedWrap(trimmed)) {
|
|
176
|
+
trimmed = trimmed.slice(1, -1).trim();
|
|
177
|
+
}
|
|
178
|
+
const match = identifierPattern.exec(trimmed);
|
|
179
|
+
return match && columnNames.includes(match[1]) ? match[1] : null;
|
|
180
|
+
};
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const kept = [];
|
|
183
|
+
for (let i = clauses.length - 1; i >= 0; i--) {
|
|
184
|
+
const col = autoCol(clauses[i]);
|
|
185
|
+
if (col && !seen.has(col)) {
|
|
186
|
+
seen.add(col);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
kept.unshift(clauses[i]);
|
|
190
|
+
}
|
|
191
|
+
return kept.join(' and ').trim();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether `[…]` is a quoted identifier (MSSQL convention). Other dialects either reuse
|
|
195
|
+
* `[` for array literals/constructors or never produce it in introspected predicates,
|
|
196
|
+
* so the default is `false` and the MSSQL helper opts in.
|
|
197
|
+
*/
|
|
198
|
+
get bracketQuotedIdentifiers() {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Splits on top-level ` AND ` (case-insensitive), ignoring matches that sit inside string
|
|
203
|
+
* literals, quoted identifiers, or parenthesized groups — so a predicate like
|
|
204
|
+
* `'foo AND bar' = col` or `(a AND b) OR c` is not mis-split.
|
|
205
|
+
*/
|
|
206
|
+
splitTopLevelAnd(s) {
|
|
207
|
+
const parts = [];
|
|
208
|
+
let depth = 0;
|
|
209
|
+
let quote = null;
|
|
210
|
+
let start = 0;
|
|
211
|
+
let i = 0;
|
|
212
|
+
while (i < s.length) {
|
|
213
|
+
const c = s[i];
|
|
214
|
+
if (quote) {
|
|
215
|
+
// Handle SQL's doubled-delimiter escape inside quoted strings/identifiers:
|
|
216
|
+
// `'` → `''`, `"` → `""`, `` ` `` → ```` `` ````, MSSQL `]` → `]]`.
|
|
217
|
+
if (c === quote && s[i + 1] === quote) {
|
|
218
|
+
i += 2;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (c === quote) {
|
|
222
|
+
quote = null;
|
|
223
|
+
}
|
|
224
|
+
i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
228
|
+
quote = c;
|
|
229
|
+
i++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (c === '[' && this.bracketQuotedIdentifiers) {
|
|
233
|
+
quote = ']';
|
|
234
|
+
i++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (c === '(') {
|
|
238
|
+
depth++;
|
|
239
|
+
i++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (c === ')') {
|
|
243
|
+
depth--;
|
|
244
|
+
i++;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (depth === 0 && /\s/.test(c)) {
|
|
248
|
+
const m = /^\s+and\s+/i.exec(s.slice(i));
|
|
249
|
+
if (m) {
|
|
250
|
+
parts.push(s.slice(start, i).trim());
|
|
251
|
+
i += m[0].length;
|
|
252
|
+
start = i;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
i++;
|
|
257
|
+
}
|
|
258
|
+
parts.push(s.slice(start).trim());
|
|
259
|
+
return parts.filter(p => p.length > 0);
|
|
260
|
+
}
|
|
261
|
+
/** Returns true iff the leading `(` matches the trailing `)` (i.e. they wrap the whole string). */
|
|
262
|
+
isBalancedWrap(s) {
|
|
263
|
+
let depth = 0;
|
|
264
|
+
for (let i = 0; i < s.length; i++) {
|
|
265
|
+
if (s[i] === '(') {
|
|
266
|
+
depth++;
|
|
267
|
+
}
|
|
268
|
+
else if (s[i] === ')') {
|
|
269
|
+
depth--;
|
|
270
|
+
if (depth === 0 && i < s.length - 1) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return depth === 0;
|
|
276
|
+
}
|
|
130
277
|
/**
|
|
131
278
|
* Build the column list for an index, supporting advanced options like sort order, nulls ordering, and collation.
|
|
132
279
|
* Note: Prefix length is only supported by MySQL/MariaDB which override this method.
|
|
@@ -204,6 +351,12 @@ export class SchemaHelper {
|
|
|
204
351
|
for (const check of Object.values(diff.changedChecks)) {
|
|
205
352
|
ret.push(this.dropConstraint(diff.name, check.name));
|
|
206
353
|
}
|
|
354
|
+
for (const trigger of Object.values(diff.removedTriggers)) {
|
|
355
|
+
ret.push(this.dropTrigger(diff.toTable, trigger));
|
|
356
|
+
}
|
|
357
|
+
for (const trigger of Object.values(diff.changedTriggers)) {
|
|
358
|
+
ret.push(this.dropTrigger(diff.toTable, trigger));
|
|
359
|
+
}
|
|
207
360
|
/* v8 ignore next */
|
|
208
361
|
if (!safe && Object.values(diff.removedColumns).length > 0) {
|
|
209
362
|
ret.push(this.getDropColumnsSQL(tableName, Object.values(diff.removedColumns), schemaName));
|
|
@@ -263,6 +416,12 @@ export class SchemaHelper {
|
|
|
263
416
|
for (const check of Object.values(diff.changedChecks)) {
|
|
264
417
|
ret.push(this.createCheck(diff.toTable, check));
|
|
265
418
|
}
|
|
419
|
+
for (const trigger of Object.values(diff.addedTriggers)) {
|
|
420
|
+
ret.push(this.createTrigger(diff.toTable, trigger));
|
|
421
|
+
}
|
|
422
|
+
for (const trigger of Object.values(diff.changedTriggers)) {
|
|
423
|
+
ret.push(this.createTrigger(diff.toTable, trigger));
|
|
424
|
+
}
|
|
266
425
|
if ('changedComment' in diff) {
|
|
267
426
|
ret.push(this.alterTableComment(diff.toTable, diff.changedComment));
|
|
268
427
|
}
|
|
@@ -521,6 +680,9 @@ export class SchemaHelper {
|
|
|
521
680
|
for (const check of table.getChecks()) {
|
|
522
681
|
this.append(ret, this.createCheck(table, check));
|
|
523
682
|
}
|
|
683
|
+
for (const trigger of table.getTriggers()) {
|
|
684
|
+
this.append(ret, this.createTrigger(table, trigger));
|
|
685
|
+
}
|
|
524
686
|
}
|
|
525
687
|
return ret;
|
|
526
688
|
}
|
|
@@ -600,6 +762,33 @@ export class SchemaHelper {
|
|
|
600
762
|
createCheck(table, check) {
|
|
601
763
|
return `alter table ${table.getQuotedName()} add constraint ${this.quote(check.name)} check (${check.expression})`;
|
|
602
764
|
}
|
|
765
|
+
/**
|
|
766
|
+
* Generates SQL to create a database trigger on a table.
|
|
767
|
+
* Override in driver-specific helpers for custom DDL (e.g., PostgreSQL function wrapping).
|
|
768
|
+
*/
|
|
769
|
+
/* v8 ignore next 10 */
|
|
770
|
+
createTrigger(table, trigger) {
|
|
771
|
+
if (trigger.expression) {
|
|
772
|
+
return trigger.expression;
|
|
773
|
+
}
|
|
774
|
+
const timing = trigger.timing.toUpperCase();
|
|
775
|
+
const events = trigger.events.map(e => e.toUpperCase()).join(' OR ');
|
|
776
|
+
const forEach = trigger.forEach === 'statement' ? 'STATEMENT' : 'ROW';
|
|
777
|
+
const when = trigger.when ? ` when (${trigger.when})` : '';
|
|
778
|
+
return `create trigger ${this.quote(trigger.name)} ${timing} ${events} on ${table.getQuotedName()} for each ${forEach}${when} begin ${trigger.body}; end`;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Generates SQL to drop a database trigger from a table.
|
|
782
|
+
* Override in driver-specific helpers for custom DDL.
|
|
783
|
+
*/
|
|
784
|
+
dropTrigger(table, trigger) {
|
|
785
|
+
if (trigger.events.length > 1) {
|
|
786
|
+
return trigger.events
|
|
787
|
+
.map(event => `drop trigger if exists ${this.quote(`${trigger.name}_${event}`)}`)
|
|
788
|
+
.join(';\n');
|
|
789
|
+
}
|
|
790
|
+
return `drop trigger if exists ${this.quote(trigger.name)}`;
|
|
791
|
+
}
|
|
603
792
|
/** @internal */
|
|
604
793
|
getTableName(table, schema) {
|
|
605
794
|
if (schema && schema !== this.platform.getDefaultSchemaName()) {
|
|
@@ -300,11 +300,18 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
|
|
|
300
300
|
for (const check of newTable.getChecks()) {
|
|
301
301
|
this.append(sql, this.helper.createCheck(newTable, check));
|
|
302
302
|
}
|
|
303
|
+
for (const trigger of newTable.getTriggers()) {
|
|
304
|
+
this.append(sql, this.helper.createTrigger(newTable, trigger));
|
|
305
|
+
}
|
|
303
306
|
this.append(ret, sql, true);
|
|
304
307
|
}
|
|
305
308
|
}
|
|
306
309
|
if (options.dropTables && !options.safe) {
|
|
307
310
|
for (const table of Object.values(schemaDiff.removedTables)) {
|
|
311
|
+
// Drop triggers before the table so driver-specific cleanup runs (e.g. PostgreSQL function removal)
|
|
312
|
+
for (const trigger of table.getTriggers()) {
|
|
313
|
+
this.append(ret, this.helper.dropTrigger(table, trigger));
|
|
314
|
+
}
|
|
308
315
|
this.append(ret, this.helper.dropTableIfExists(table.name, table.schema));
|
|
309
316
|
}
|
|
310
317
|
if (Utils.hasObjectKeys(schemaDiff.removedTables)) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { splitCommaSeparatedIdentifiers, type EntityMetadata, type EntityPartitionBy } from '@mikro-orm/core';
|
|
2
|
+
import type { TablePartitioning } from '../typings.js';
|
|
3
|
+
export { splitCommaSeparatedIdentifiers };
|
|
4
|
+
/** @internal */
|
|
5
|
+
export declare function normalizePartitionDefinition(value: string): string;
|
|
6
|
+
/** @internal */
|
|
7
|
+
export declare function normalizePartitionBound(value: string): string;
|
|
8
|
+
/** @internal */
|
|
9
|
+
export declare const getTablePartitioning: (meta: EntityMetadata, tableSchema: string | undefined, quoteIdentifier?: (id: string) => string) => TablePartitioning | undefined;
|
|
10
|
+
/** @internal */
|
|
11
|
+
export declare const diffPartitioning: (from: TablePartitioning | undefined, to: TablePartitioning | undefined, defaultSchema: string | undefined) => boolean;
|
|
12
|
+
/** @internal */
|
|
13
|
+
export declare const toEntityPartitionBy: (partitioning: TablePartitioning | undefined, parentTableName?: string, parentSchema?: string) => EntityPartitionBy | undefined;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { splitCommaSeparatedIdentifiers } from '@mikro-orm/core';
|
|
2
|
+
export { splitCommaSeparatedIdentifiers };
|
|
3
|
+
const skipQuotedLiteral = (value, start) => {
|
|
4
|
+
let i = start + 1;
|
|
5
|
+
while (i < value.length) {
|
|
6
|
+
if (value[i] === "'") {
|
|
7
|
+
if (value[i + 1] === "'") {
|
|
8
|
+
i += 2;
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
return i;
|
|
12
|
+
}
|
|
13
|
+
i++;
|
|
14
|
+
}
|
|
15
|
+
// Unterminated literal — point past the end so callers' `slice(start, end + 1)` includes
|
|
16
|
+
// the full remaining tail instead of dropping its last character.
|
|
17
|
+
return value.length;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Apply `transform` only to segments of `value` that lie outside single-quoted
|
|
21
|
+
* SQL literals, leaving literal content (including escaped `''`) untouched.
|
|
22
|
+
*/
|
|
23
|
+
const mapOutsideLiterals = (value, transform) => {
|
|
24
|
+
let ret = '';
|
|
25
|
+
let buffer = '';
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < value.length) {
|
|
28
|
+
if (value[i] === "'") {
|
|
29
|
+
ret += transform(buffer);
|
|
30
|
+
buffer = '';
|
|
31
|
+
const end = skipQuotedLiteral(value, i);
|
|
32
|
+
ret += value.slice(i, end + 1);
|
|
33
|
+
i = end + 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
buffer += value[i];
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
return ret + transform(buffer);
|
|
40
|
+
};
|
|
41
|
+
const collapseWhitespace = (value) => value.replace(/\s+/g, ' ');
|
|
42
|
+
const normalizeWhitespace = (value) => mapOutsideLiterals(value, collapseWhitespace).trim();
|
|
43
|
+
const stripDoubleQuotes = (value) => mapOutsideLiterals(value, s => s.replaceAll('"', ''));
|
|
44
|
+
const normalizeQuotedIdentifiers = (value) => stripDoubleQuotes(normalizeWhitespace(value));
|
|
45
|
+
const findMatchingParenthesis = (value, start) => {
|
|
46
|
+
let depth = 0;
|
|
47
|
+
for (let i = start; i < value.length; i++) {
|
|
48
|
+
if (value[i] === "'") {
|
|
49
|
+
i = skipQuotedLiteral(value, i);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (value[i] === '(') {
|
|
53
|
+
depth++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (value[i] === ')') {
|
|
57
|
+
depth--;
|
|
58
|
+
if (depth === 0) {
|
|
59
|
+
return i;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return -1;
|
|
64
|
+
};
|
|
65
|
+
const normalizePartitionLiterals = (value) => value
|
|
66
|
+
// PG pg_get_expr output often tacks `::text` onto string literals inside expressions; drop it
|
|
67
|
+
// so the catalog shape matches user-provided bounds. This applies symmetrically to both
|
|
68
|
+
// user metadata and catalog reads, so diffing converges. If a user intentionally writes a
|
|
69
|
+
// `::text` cast in a bound literal it will be stripped on both sides as well.
|
|
70
|
+
.replace(/('(?:[^']|'')*')::text\b/gi, '$1')
|
|
71
|
+
// Strip the `00:00:00` time component so catalog round-trips (timestamp[tz] bounds formatted
|
|
72
|
+
// via the session TimeZone) match user metadata that omitted the time part. Only collapse
|
|
73
|
+
// when we can confidently attribute the literal to a timestamp column: either a numeric
|
|
74
|
+
// offset is present (timestamptz catalog output) or an explicit `::timestamp[tz]` cast
|
|
75
|
+
// follows the literal. Bare `'YYYY-MM-DD 00:00:00'` without offset/cast could just as easily
|
|
76
|
+
// be a text/varchar list-partition value, and collapsing it would produce false-negative
|
|
77
|
+
// diffs.
|
|
78
|
+
.replace(/'(\d{4}-\d{2}-\d{2}) 00:00:00[+-]\d{2}(?::\d{2})?'/g, "'$1'")
|
|
79
|
+
.replace(/'(\d{4}-\d{2}-\d{2}) 00:00:00'(?=\s*::\s*timestamp(?:tz)?(?:\s+(?:with|without)\s+time\s+zone)?\b)/gi, "'$1'");
|
|
80
|
+
const unwrapOuterParentheses = (value) => {
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) {
|
|
83
|
+
return trimmed;
|
|
84
|
+
}
|
|
85
|
+
if (findMatchingParenthesis(trimmed, 0) !== trimmed.length - 1) {
|
|
86
|
+
return trimmed;
|
|
87
|
+
}
|
|
88
|
+
return trimmed.slice(1, -1).trim();
|
|
89
|
+
};
|
|
90
|
+
const unwrapAllOuterParentheses = (value) => {
|
|
91
|
+
let current = value.trim();
|
|
92
|
+
while (current.startsWith('(')) {
|
|
93
|
+
const unwrapped = unwrapOuterParentheses(current);
|
|
94
|
+
if (unwrapped === current) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
current = unwrapped;
|
|
98
|
+
}
|
|
99
|
+
return current;
|
|
100
|
+
};
|
|
101
|
+
const normalizePartitionSqlFragment = (value) => {
|
|
102
|
+
const normalized = stripDoubleQuotes(normalizeWhitespace(normalizePartitionLiterals(value)));
|
|
103
|
+
let ret = '';
|
|
104
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
105
|
+
if (normalized[i] === "'") {
|
|
106
|
+
const end = skipQuotedLiteral(normalized, i);
|
|
107
|
+
ret += normalized.slice(i, end + 1);
|
|
108
|
+
i = end;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (normalized[i] === '(') {
|
|
112
|
+
const end = findMatchingParenthesis(normalized, i);
|
|
113
|
+
if (end === -1) {
|
|
114
|
+
ret += normalized.slice(i);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
const inner = unwrapAllOuterParentheses(normalizePartitionSqlFragment(normalized.slice(i + 1, end)));
|
|
118
|
+
ret += `(${inner})`;
|
|
119
|
+
i = end;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
ret += normalized[i];
|
|
123
|
+
}
|
|
124
|
+
return normalizeWhitespace(unwrapAllOuterParentheses(ret));
|
|
125
|
+
};
|
|
126
|
+
const unquoteIdentifier = (value) => {
|
|
127
|
+
const trimmed = value.trim();
|
|
128
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
129
|
+
return trimmed.slice(1, -1).replaceAll('""', '"');
|
|
130
|
+
}
|
|
131
|
+
return trimmed;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Split a user-supplied partition name into `{ schema, name }`. Supports bare (`child`),
|
|
135
|
+
* schema-qualified (`schema.child`), and quoted (`"my.schema"."child"`) forms. Dots inside
|
|
136
|
+
* double-quoted identifiers are part of the identifier and do not split.
|
|
137
|
+
*/
|
|
138
|
+
const splitPartitionName = (name) => {
|
|
139
|
+
let depth = 0;
|
|
140
|
+
for (let i = 0; i < name.length; i++) {
|
|
141
|
+
const ch = name[i];
|
|
142
|
+
if (ch === '"') {
|
|
143
|
+
if (name[i + 1] === '"') {
|
|
144
|
+
i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
depth = depth === 0 ? 1 : 0;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ch === '.' && depth === 0) {
|
|
151
|
+
return {
|
|
152
|
+
schema: unquoteIdentifier(name.slice(0, i)),
|
|
153
|
+
name: unquoteIdentifier(name.slice(i + 1)),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { name: unquoteIdentifier(name) };
|
|
158
|
+
};
|
|
159
|
+
const resolvePartitionKey = (meta, key, quoteIdentifier) => {
|
|
160
|
+
const trimmed = key.trim().replaceAll('"', '');
|
|
161
|
+
if (!trimmed) {
|
|
162
|
+
throw new Error(`Entity ${meta.className} has invalid partitionBy option: empty partition key`);
|
|
163
|
+
}
|
|
164
|
+
const prop = meta.root.properties[trimmed] ??
|
|
165
|
+
Object.values(meta.root.properties).find(candidate => candidate.fieldNames?.length === 1 && candidate.fieldNames[0] === trimmed);
|
|
166
|
+
if (!prop) {
|
|
167
|
+
throw new Error(`Entity ${meta.className} has invalid partitionBy option: unknown partition key '${key.trim()}'`);
|
|
168
|
+
}
|
|
169
|
+
if (prop.fieldNames?.length !== 1) {
|
|
170
|
+
throw new Error(`Entity ${meta.className} has invalid partitionBy option: partition key '${key.trim()}' maps to multiple columns ('${prop.fieldNames?.join("', '")}'); list them explicitly as partition keys`);
|
|
171
|
+
}
|
|
172
|
+
return quoteIdentifier(prop.fieldNames[0]);
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the partition expression to a SQL fragment. Column-reference forms (array of keys
|
|
176
|
+
* or a clean comma-list of identifiers) are rewritten to the backing `fieldNames` and passed
|
|
177
|
+
* through `quoteIdentifier`. The callback form and the raw-SQL fallback (anything that isn't
|
|
178
|
+
* a clean identifier list, e.g. `date_trunc('day', created_at)`) are emitted verbatim — the
|
|
179
|
+
* user owns identifier quoting inside a raw expression.
|
|
180
|
+
*/
|
|
181
|
+
const resolvePartitionExpression = (meta, expression, quoteIdentifier) => {
|
|
182
|
+
if (typeof expression === 'function') {
|
|
183
|
+
return normalizeWhitespace(expression(meta.createSchemaColumnMappingObject()));
|
|
184
|
+
}
|
|
185
|
+
if (Array.isArray(expression)) {
|
|
186
|
+
return expression.map(key => resolvePartitionKey(meta, key, quoteIdentifier)).join(', ');
|
|
187
|
+
}
|
|
188
|
+
const trimmed = expression.trim();
|
|
189
|
+
const keys = splitCommaSeparatedIdentifiers(trimmed);
|
|
190
|
+
if (keys) {
|
|
191
|
+
return keys.map(key => resolvePartitionKey(meta, key, quoteIdentifier)).join(', ');
|
|
192
|
+
}
|
|
193
|
+
return trimmed;
|
|
194
|
+
};
|
|
195
|
+
const createPartitionDefinition = (type, expression) => `${type.toLowerCase()} (${normalizeWhitespace(expression)})`;
|
|
196
|
+
/** @internal */
|
|
197
|
+
export function normalizePartitionDefinition(value) {
|
|
198
|
+
const normalized = normalizeWhitespace(value);
|
|
199
|
+
const match = /^(\w+)\s*(.*)$/.exec(normalized);
|
|
200
|
+
const rawType = match ? match[1] : normalized;
|
|
201
|
+
const type = rawType.toLowerCase();
|
|
202
|
+
const expression = match ? match[2].trim() : '';
|
|
203
|
+
if (!expression) {
|
|
204
|
+
return type;
|
|
205
|
+
}
|
|
206
|
+
if (!expression.startsWith('(')) {
|
|
207
|
+
return `${type} ${normalizePartitionSqlFragment(expression)}`;
|
|
208
|
+
}
|
|
209
|
+
return `${type} (${normalizePartitionSqlFragment(unwrapAllOuterParentheses(expression))})`;
|
|
210
|
+
}
|
|
211
|
+
const PARTITION_BOUND_KEYWORDS = /\b(for values|with|in|from|to|minvalue|maxvalue|null)\b/gi;
|
|
212
|
+
/** @internal */
|
|
213
|
+
export function normalizePartitionBound(value) {
|
|
214
|
+
const normalized = normalizeWhitespace(value);
|
|
215
|
+
if (!normalized) {
|
|
216
|
+
return '';
|
|
217
|
+
}
|
|
218
|
+
if (/^default$/i.test(normalized)) {
|
|
219
|
+
return 'default';
|
|
220
|
+
}
|
|
221
|
+
// Prepend `for values` if the caller passed a bare `with/in/from … to …` clause, then lowercase
|
|
222
|
+
// PG bound keywords outside quoted literals (so `FROM (MINVALUE) TO ('hello TO world')` becomes
|
|
223
|
+
// `from (minvalue) to ('hello TO world')` with the inner TO inside the literal preserved).
|
|
224
|
+
// PG's `pg_get_expr` emits `MINVALUE`/`MAXVALUE`/`NULL` in uppercase, so case-folding them here
|
|
225
|
+
// prevents a perpetual diff against user-supplied lowercase bounds.
|
|
226
|
+
const prefixed = /^for values\b/i.test(normalized) ? normalized : `for values ${normalized}`;
|
|
227
|
+
const lowered = mapOutsideLiterals(prefixed, segment => segment.replace(PARTITION_BOUND_KEYWORDS, match => match.toLowerCase()));
|
|
228
|
+
return normalizePartitionSqlFragment(lowered);
|
|
229
|
+
}
|
|
230
|
+
const createPartitionBound = (value) => normalizePartitionBound(value);
|
|
231
|
+
const createHashPartitions = (tableName, tableSchema, partitions) => {
|
|
232
|
+
const count = typeof partitions === 'number' ? partitions : partitions.length;
|
|
233
|
+
return Array.from({ length: count }, (_, remainder) => {
|
|
234
|
+
const bound = normalizePartitionBound(`with (modulus ${count}, remainder ${remainder})`);
|
|
235
|
+
if (typeof partitions === 'number') {
|
|
236
|
+
return { name: `${tableName}_${remainder}`, schema: tableSchema, bound };
|
|
237
|
+
}
|
|
238
|
+
const { name, schema } = splitPartitionName(partitions[remainder]);
|
|
239
|
+
return { name, schema: schema ?? tableSchema, bound };
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
const createExplicitPartitions = (tableName, tableSchema, partitions) => partitions.map((partition, index) => {
|
|
243
|
+
const resolvedName = partition.name ?? `${tableName}_${index}`;
|
|
244
|
+
const { name, schema } = splitPartitionName(resolvedName);
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
schema: schema ?? tableSchema,
|
|
248
|
+
bound: createPartitionBound(partition.values),
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
/** @internal */
|
|
252
|
+
export const getTablePartitioning = (meta, tableSchema, quoteIdentifier = id => id) => {
|
|
253
|
+
if (!meta.partitionBy) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const definition = createPartitionDefinition(meta.partitionBy.type, resolvePartitionExpression(meta, meta.partitionBy.expression, quoteIdentifier));
|
|
257
|
+
const partitions = meta.partitionBy.type === 'hash'
|
|
258
|
+
? createHashPartitions(meta.tableName, tableSchema, meta.partitionBy.partitions)
|
|
259
|
+
: createExplicitPartitions(meta.tableName, tableSchema, meta.partitionBy.partitions);
|
|
260
|
+
return { definition, partitions };
|
|
261
|
+
};
|
|
262
|
+
/** @internal */
|
|
263
|
+
export const diffPartitioning = (from, to, defaultSchema) => {
|
|
264
|
+
if (!from && !to) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
if (!from || !to) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (normalizeQuotedIdentifiers(normalizePartitionDefinition(from.definition)) !==
|
|
271
|
+
normalizeQuotedIdentifiers(normalizePartitionDefinition(to.definition))) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
if (from.partitions.length !== to.partitions.length) {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
const normalizeSchema = (schema) => (schema && schema !== defaultSchema ? schema : '');
|
|
278
|
+
const serializePartition = (partition) => `${normalizeSchema(partition.schema)}.${partition.name}:${normalizeQuotedIdentifiers(normalizePartitionBound(partition.bound))}`;
|
|
279
|
+
const fromPartitions = from.partitions.map(serializePartition).sort();
|
|
280
|
+
const toPartitions = to.partitions.map(serializePartition).sort();
|
|
281
|
+
return fromPartitions.some((partition, index) => partition !== toPartitions[index]);
|
|
282
|
+
};
|
|
283
|
+
const SUPPORTED_PARTITION_TYPES = ['hash', 'list', 'range'];
|
|
284
|
+
const isSupportedPartitionType = (value) => SUPPORTED_PARTITION_TYPES.includes(value);
|
|
285
|
+
/** @internal */
|
|
286
|
+
export const toEntityPartitionBy = (partitioning, parentTableName, parentSchema) => {
|
|
287
|
+
if (!partitioning) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
const normalizedDefinition = normalizePartitionDefinition(partitioning.definition);
|
|
291
|
+
const normalizedPartitions = partitioning.partitions.map(partition => ({
|
|
292
|
+
...partition,
|
|
293
|
+
bound: normalizePartitionBound(partition.bound),
|
|
294
|
+
}));
|
|
295
|
+
// Split the leading type keyword off of the definition without using `split(' ')`, which would
|
|
296
|
+
// shatter quoted literals containing spaces. Match a bareword prefix followed by whitespace.
|
|
297
|
+
const [, rawType = normalizedDefinition, rawExpression = ''] = /^(\S+)(?:\s+([\s\S]*))?$/.exec(normalizeWhitespace(normalizedDefinition)) ?? [];
|
|
298
|
+
const type = rawType.toLowerCase();
|
|
299
|
+
if (!isSupportedPartitionType(type)) {
|
|
300
|
+
throw new Error(`Unsupported partition type '${rawType}' in definition '${partitioning.definition}'`);
|
|
301
|
+
}
|
|
302
|
+
const expression = unwrapOuterParentheses(rawExpression);
|
|
303
|
+
const qualify = (partition) => partition.schema && partition.schema !== parentSchema ? `${partition.schema}.${partition.name}` : partition.name;
|
|
304
|
+
if (type === 'hash') {
|
|
305
|
+
// Collapse to a bare count when catalog names follow the default
|
|
306
|
+
// `${parentTableName}_${remainder}` pattern and live in the parent's schema, or when we have
|
|
307
|
+
// no parent context to compare against (backwards-compatible behavior for callers that pass
|
|
308
|
+
// just the `TablePartitioning`). Otherwise preserve the explicit name array so the next DDL
|
|
309
|
+
// generation reproduces the same children.
|
|
310
|
+
const usesDefaultShape = parentTableName == null ||
|
|
311
|
+
normalizedPartitions.every((p, i) => p.name === `${parentTableName}_${i}` && (!p.schema || p.schema === parentSchema));
|
|
312
|
+
return {
|
|
313
|
+
type,
|
|
314
|
+
expression,
|
|
315
|
+
partitions: usesDefaultShape ? normalizedPartitions.length : normalizedPartitions.map(qualify),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
type,
|
|
320
|
+
expression,
|
|
321
|
+
partitions: normalizedPartitions.map(partition => ({
|
|
322
|
+
name: qualify(partition),
|
|
323
|
+
values: partition.bound === 'default' ? 'default' : partition.bound.replace(/^for values\s+/i, ''),
|
|
324
|
+
})),
|
|
325
|
+
};
|
|
326
|
+
};
|
package/typings.d.ts
CHANGED
|
@@ -71,6 +71,11 @@ export interface IndexDef {
|
|
|
71
71
|
primary: boolean;
|
|
72
72
|
composite?: boolean;
|
|
73
73
|
expression?: string;
|
|
74
|
+
/**
|
|
75
|
+
* WHERE predicate for partial indexes, normalized to a SQL fragment after metadata
|
|
76
|
+
* resolution and introspection. Mutually exclusive with `expression`.
|
|
77
|
+
*/
|
|
78
|
+
where?: string;
|
|
74
79
|
options?: Dictionary;
|
|
75
80
|
type?: string | Readonly<{
|
|
76
81
|
indexType?: string;
|
|
@@ -108,6 +113,25 @@ export interface CheckDef<T = unknown> {
|
|
|
108
113
|
definition?: string;
|
|
109
114
|
columnName?: string;
|
|
110
115
|
}
|
|
116
|
+
/** Resolved trigger definition for schema operations (all callbacks resolved to strings). */
|
|
117
|
+
export interface SqlTriggerDef {
|
|
118
|
+
name: string;
|
|
119
|
+
timing: 'before' | 'after' | 'instead of';
|
|
120
|
+
events: ('insert' | 'update' | 'delete' | 'truncate')[];
|
|
121
|
+
forEach: 'row' | 'statement';
|
|
122
|
+
body: string;
|
|
123
|
+
when?: string;
|
|
124
|
+
expression?: string;
|
|
125
|
+
}
|
|
126
|
+
export interface TablePartition {
|
|
127
|
+
name: string;
|
|
128
|
+
schema?: string;
|
|
129
|
+
bound: string;
|
|
130
|
+
}
|
|
131
|
+
export interface TablePartitioning {
|
|
132
|
+
definition: string;
|
|
133
|
+
partitions: TablePartition[];
|
|
134
|
+
}
|
|
111
135
|
export interface ColumnDifference {
|
|
112
136
|
oldColumnName: string;
|
|
113
137
|
column: Column;
|
|
@@ -117,6 +141,10 @@ export interface ColumnDifference {
|
|
|
117
141
|
export interface TableDifference {
|
|
118
142
|
name: string;
|
|
119
143
|
changedComment?: string;
|
|
144
|
+
changedPartitioning?: {
|
|
145
|
+
from?: TablePartitioning;
|
|
146
|
+
to?: TablePartitioning;
|
|
147
|
+
};
|
|
120
148
|
fromTable: DatabaseTable;
|
|
121
149
|
toTable: DatabaseTable;
|
|
122
150
|
addedColumns: Dictionary<Column>;
|
|
@@ -130,6 +158,9 @@ export interface TableDifference {
|
|
|
130
158
|
addedChecks: Dictionary<CheckDef>;
|
|
131
159
|
changedChecks: Dictionary<CheckDef>;
|
|
132
160
|
removedChecks: Dictionary<CheckDef>;
|
|
161
|
+
addedTriggers: Dictionary<SqlTriggerDef>;
|
|
162
|
+
changedTriggers: Dictionary<SqlTriggerDef>;
|
|
163
|
+
removedTriggers: Dictionary<SqlTriggerDef>;
|
|
133
164
|
addedForeignKeys: Dictionary<ForeignKey>;
|
|
134
165
|
changedForeignKeys: Dictionary<ForeignKey>;
|
|
135
166
|
removedForeignKeys: Dictionary<ForeignKey>;
|
|
@@ -233,7 +264,7 @@ export interface ICriteriaNode<T extends object> {
|
|
|
233
264
|
shouldInline(payload: any): boolean;
|
|
234
265
|
willAutoJoin(qb: IQueryBuilder<T>, alias?: string, options?: ICriteriaNodeProcessOptions): boolean;
|
|
235
266
|
shouldRename(payload: any): boolean;
|
|
236
|
-
renameFieldToPK<T>(qb: IQueryBuilder<T>, ownerAlias?: string): string;
|
|
267
|
+
renameFieldToPK<T>(qb: IQueryBuilder<T>, ownerAlias?: string, options?: ICriteriaNodeProcessOptions): string;
|
|
237
268
|
getPath(opts?: {
|
|
238
269
|
addIndex?: boolean;
|
|
239
270
|
}): string;
|