@mikro-orm/sql 7.1.0-dev.14 → 7.1.0-dev.16
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/AbstractSqlDriver.d.ts +7 -0
- package/AbstractSqlDriver.js +51 -0
- package/dialects/mysql/MySqlSchemaHelper.d.ts +5 -2
- package/dialects/mysql/MySqlSchemaHelper.js +19 -4
- package/dialects/postgresql/BasePostgreSqlPlatform.d.ts +1 -0
- package/dialects/postgresql/BasePostgreSqlPlatform.js +3 -0
- package/dialects/postgresql/PostgreSqlSchemaHelper.d.ts +14 -1
- package/dialects/postgresql/PostgreSqlSchemaHelper.js +105 -3
- package/dialects/sqlite/SqliteSchemaHelper.d.ts +1 -0
- package/dialects/sqlite/SqliteSchemaHelper.js +15 -2
- package/package.json +2 -2
- package/schema/DatabaseSchema.js +4 -0
- package/schema/DatabaseTable.d.ts +7 -1
- package/schema/DatabaseTable.js +32 -3
- package/schema/SchemaComparator.js +32 -1
- package/schema/SchemaHelper.d.ts +40 -0
- package/schema/SchemaHelper.js +149 -2
- package/schema/partitioning.d.ts +13 -0
- package/schema/partitioning.js +326 -0
- package/typings.d.ts +18 -0
package/AbstractSqlDriver.d.ts
CHANGED
|
@@ -147,6 +147,13 @@ export declare abstract class AbstractSqlDriver<Connection extends AbstractSqlCo
|
|
|
147
147
|
mapPropToFieldNames<T extends object>(qb: AnyQueryBuilder<T>, prop: EntityProperty<T>, tableAlias: string, meta: EntityMetadata<T>, schema?: string, explicitFields?: readonly InternalField<T>[]): InternalField<T>[];
|
|
148
148
|
/** @internal */
|
|
149
149
|
createQueryBuilder<T extends object>(entityName: EntityName<T> | AnyQueryBuilder<T>, ctx?: Transaction, preferredConnectionType?: ConnectionType, convertCustomTypes?: boolean, loggerContext?: LoggingOptions, alias?: string, em?: SqlEntityManager): AnyQueryBuilder<T>;
|
|
150
|
+
/**
|
|
151
|
+
* Renders a `FilterQuery` predicate into a SQL fragment (without the `WHERE` keyword and
|
|
152
|
+
* without table-alias prefixes) suitable for inlining into a partial-index DDL statement.
|
|
153
|
+
* Used by `DatabaseTable.addIndex` when the user passes an object `where` on `@Index` /
|
|
154
|
+
* `@Unique`. Strings are returned unchanged.
|
|
155
|
+
*/
|
|
156
|
+
renderPartialIndexWhere<T extends object>(entityName: EntityName<T>, where: string | FilterQuery<T>): string;
|
|
150
157
|
protected resolveConnectionType(args: {
|
|
151
158
|
ctx?: Transaction;
|
|
152
159
|
connectionType?: ConnectionType;
|
package/AbstractSqlDriver.js
CHANGED
|
@@ -1944,6 +1944,57 @@ export class AbstractSqlDriver extends DatabaseDriver {
|
|
|
1944
1944
|
}
|
|
1945
1945
|
return qb;
|
|
1946
1946
|
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Renders a `FilterQuery` predicate into a SQL fragment (without the `WHERE` keyword and
|
|
1949
|
+
* without table-alias prefixes) suitable for inlining into a partial-index DDL statement.
|
|
1950
|
+
* Used by `DatabaseTable.addIndex` when the user passes an object `where` on `@Index` /
|
|
1951
|
+
* `@Unique`. Strings are returned unchanged.
|
|
1952
|
+
*/
|
|
1953
|
+
renderPartialIndexWhere(entityName, where) {
|
|
1954
|
+
if (typeof where === 'string') {
|
|
1955
|
+
return where;
|
|
1956
|
+
}
|
|
1957
|
+
const name = Utils.className(entityName);
|
|
1958
|
+
if (where == null || (Utils.isPlainObject(where) && Object.keys(where).length === 0)) {
|
|
1959
|
+
throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` is empty.`);
|
|
1960
|
+
}
|
|
1961
|
+
const alias = '__p';
|
|
1962
|
+
const qb = this.createQueryBuilder(entityName, undefined, undefined, undefined, undefined, alias);
|
|
1963
|
+
qb.where(where);
|
|
1964
|
+
const sql = qb.getFormattedQuery();
|
|
1965
|
+
// Relation traversal produces join clauses whose aliased identifiers can't be inlined
|
|
1966
|
+
// into a CREATE INDEX ... WHERE clause — reject with a clear error rather than emitting broken DDL.
|
|
1967
|
+
if (/\bjoin\b/i.test(sql.split(/\bwhere\b/i)[0])) {
|
|
1968
|
+
throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` may not traverse relations.`);
|
|
1969
|
+
}
|
|
1970
|
+
// Anchor at end-of-string only — the synthetic QB has no top-level order by / limit /
|
|
1971
|
+
// group by / having / offset, so any such keyword inside the captured predicate is
|
|
1972
|
+
// inside a subquery and must not terminate the match.
|
|
1973
|
+
const match = /\bwhere\s+([\s\S]+)$/i.exec(sql);
|
|
1974
|
+
if (!match) {
|
|
1975
|
+
throw new Error(`Failed to render partial-index predicate for entity '${name}': ${sql}`);
|
|
1976
|
+
}
|
|
1977
|
+
const quote = (s) => this.platform.quoteIdentifier(s);
|
|
1978
|
+
const aliasPrefix = new RegExp(`${quote(alias).replace(/[[\]]/g, '\\$&')}\\.`, 'g');
|
|
1979
|
+
const stripped = match[1].replace(aliasPrefix, '').trim();
|
|
1980
|
+
// Any qualified column reference remaining after the alias strip points at another table or
|
|
1981
|
+
// subquery and can't be inlined into a CREATE INDEX ... WHERE predicate. Covers both
|
|
1982
|
+
// QB-generated sub-aliases (quoted, e.g. `"e0"."col"`) and raw fragments with bare refs
|
|
1983
|
+
// (e.g. `raw('other_table.col = 1')`). String literals are erased first so dots inside
|
|
1984
|
+
// them (e.g. JSON path operands like `'$.path'`) don't trip the guard.
|
|
1985
|
+
// Both patterns use a `(?!\s*\()` lookahead so schema-qualified function calls
|
|
1986
|
+
// (`pg_catalog.lower(name)`, `"public".my_func(col)`) are accepted — only `<id>.<id>` not
|
|
1987
|
+
// followed by `(` is treated as a cross-table column reference.
|
|
1988
|
+
const withoutStrings = stripped.replace(/'(?:[^']|'')*'/g, "''");
|
|
1989
|
+
const quotedIdent = String.raw `(?:"(?:[^"]|"")+"|\`(?:[^\`]|\`\`)+\`|\[(?:[^\]]|\]\])+\])`;
|
|
1990
|
+
const anyIdent = `(?:${quotedIdent}|[A-Za-z_]\\w*)`;
|
|
1991
|
+
const quotedCrossRef = new RegExp(`${quotedIdent}\\s*\\.\\s*${anyIdent}(?!\\s*\\()`);
|
|
1992
|
+
const bareCrossRef = /\b[A-Za-z_]\w*\s*\.\s*[A-Za-z_]\w*\b(?!\s*\()/;
|
|
1993
|
+
if (quotedCrossRef.test(withoutStrings) || bareCrossRef.test(withoutStrings)) {
|
|
1994
|
+
throw new Error(`Cannot render partial-index predicate for entity '${name}': \`where\` references another table or subquery which cannot be inlined into a CREATE INDEX ... WHERE clause.`);
|
|
1995
|
+
}
|
|
1996
|
+
return stripped;
|
|
1997
|
+
}
|
|
1947
1998
|
resolveConnectionType(args) {
|
|
1948
1999
|
if (args.ctx) {
|
|
1949
2000
|
return 'write';
|
|
@@ -11,6 +11,7 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
|
|
|
11
11
|
'current_timestamp(?)': string[];
|
|
12
12
|
'0': string[];
|
|
13
13
|
};
|
|
14
|
+
private static readonly PARTIAL_INDEX_RE;
|
|
14
15
|
getSchemaBeginning(charset: string, disableForeignKeys?: boolean): string;
|
|
15
16
|
disableForeignKeysSQL(): string;
|
|
16
17
|
enableForeignKeysSQL(): string;
|
|
@@ -22,8 +23,10 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
|
|
|
22
23
|
getAllIndexes(connection: AbstractSqlConnection, tables: Table[], ctx?: Transaction): Promise<Dictionary<IndexDef[]>>;
|
|
23
24
|
getCreateIndexSQL(tableName: string, index: IndexDef, partialExpression?: boolean): string;
|
|
24
25
|
/**
|
|
25
|
-
* Build the column list for a MySQL index
|
|
26
|
-
*
|
|
26
|
+
* Build the column list for a MySQL index. MySQL requires collation via an expression:
|
|
27
|
+
* `(column COLLATE collation_name)`. Partial indexes (`where`) are emulated via functional
|
|
28
|
+
* indexes — requires MySQL 8.0.13+. MariaDB does not support inline functional indexes
|
|
29
|
+
* and overrides to throw at a higher level.
|
|
27
30
|
*/
|
|
28
31
|
protected getIndexColumns(index: IndexDef): string;
|
|
29
32
|
/**
|
|
@@ -7,6 +7,10 @@ export class MySqlSchemaHelper extends SchemaHelper {
|
|
|
7
7
|
'current_timestamp(?)': ['current_timestamp(?)'],
|
|
8
8
|
'0': ['0', 'false'],
|
|
9
9
|
};
|
|
10
|
+
// Greedy `(.+)` so nested CASE expressions inside the predicate don't trip the match on
|
|
11
|
+
// an inner `then <col> end` — the trailing `$` anchor forces the regex engine to extend
|
|
12
|
+
// the capture to the outermost case-end boundary.
|
|
13
|
+
static PARTIAL_INDEX_RE = /^\s*\(\s*case\s+when\s+(.+)\s+then\s+`([^`]+)`\s+end\s*\)\s*$/is;
|
|
10
14
|
getSchemaBeginning(charset, disableForeignKeys) {
|
|
11
15
|
if (disableForeignKeys) {
|
|
12
16
|
return `set names ${charset};\n${this.disableForeignKeysSQL()}\n\n`;
|
|
@@ -84,8 +88,11 @@ export class MySqlSchemaHelper extends SchemaHelper {
|
|
|
84
88
|
const ret = {};
|
|
85
89
|
for (const index of allIndexes) {
|
|
86
90
|
const key = this.getTableKey(index);
|
|
91
|
+
const partialMatch = !index.column_name && typeof index.expression === 'string'
|
|
92
|
+
? MySqlSchemaHelper.PARTIAL_INDEX_RE.exec(index.expression)
|
|
93
|
+
: null;
|
|
87
94
|
const indexDef = {
|
|
88
|
-
columnNames: [index.column_name],
|
|
95
|
+
columnNames: [partialMatch ? partialMatch[2] : index.column_name],
|
|
89
96
|
keyName: index.index_name,
|
|
90
97
|
unique: !index.non_unique,
|
|
91
98
|
primary: index.index_name === 'PRIMARY',
|
|
@@ -113,7 +120,10 @@ export class MySqlSchemaHelper extends SchemaHelper {
|
|
|
113
120
|
if (index.is_visible === 'NO') {
|
|
114
121
|
indexDef.invisible = true;
|
|
115
122
|
}
|
|
116
|
-
if (
|
|
123
|
+
if (partialMatch) {
|
|
124
|
+
indexDef.where = partialMatch[1].trim();
|
|
125
|
+
}
|
|
126
|
+
else if (!index.column_name || index.expression?.match(/ where /i)) {
|
|
117
127
|
indexDef.expression = index.expression; // required for the `getCreateIndexSQL()` call
|
|
118
128
|
indexDef.expression = this.getCreateIndexSQL(index.table_name, indexDef, !!index.expression);
|
|
119
129
|
}
|
|
@@ -150,10 +160,15 @@ export class MySqlSchemaHelper extends SchemaHelper {
|
|
|
150
160
|
return this.appendMySqlIndexSuffix(sql, index);
|
|
151
161
|
}
|
|
152
162
|
/**
|
|
153
|
-
* Build the column list for a MySQL index
|
|
154
|
-
*
|
|
163
|
+
* Build the column list for a MySQL index. MySQL requires collation via an expression:
|
|
164
|
+
* `(column COLLATE collation_name)`. Partial indexes (`where`) are emulated via functional
|
|
165
|
+
* indexes — requires MySQL 8.0.13+. MariaDB does not support inline functional indexes
|
|
166
|
+
* and overrides to throw at a higher level.
|
|
155
167
|
*/
|
|
156
168
|
getIndexColumns(index) {
|
|
169
|
+
if (index.where) {
|
|
170
|
+
return this.emulatePartialIndexColumns(index);
|
|
171
|
+
}
|
|
157
172
|
if (index.columns?.length) {
|
|
158
173
|
return index.columns
|
|
159
174
|
.map(col => {
|
|
@@ -15,6 +15,7 @@ export declare class BasePostgreSqlPlatform extends AbstractSqlPlatform {
|
|
|
15
15
|
usesEnumCheckConstraints(): boolean;
|
|
16
16
|
getEnumArrayCheckConstraintExpression(column: string, items: string[]): string;
|
|
17
17
|
supportsMaterializedViews(): boolean;
|
|
18
|
+
supportsPartitionedTables(): boolean;
|
|
18
19
|
supportsCustomPrimaryKeyNames(): boolean;
|
|
19
20
|
getCurrentTimestampSQL(length: number): string;
|
|
20
21
|
getDateTimeTypeDeclarationSQL(column: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Dictionary, type Transaction } from '@mikro-orm/core';
|
|
2
2
|
import { SchemaHelper } from '../../schema/SchemaHelper.js';
|
|
3
3
|
import type { AbstractSqlConnection } from '../../AbstractSqlConnection.js';
|
|
4
|
-
import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, SqlTriggerDef } from '../../typings.js';
|
|
4
|
+
import type { CheckDef, Column, ForeignKey, IndexDef, Table, TableDifference, TablePartitioning, SqlTriggerDef } from '../../typings.js';
|
|
5
5
|
import type { DatabaseSchema } from '../../schema/DatabaseSchema.js';
|
|
6
6
|
import type { DatabaseTable } from '../../schema/DatabaseTable.js';
|
|
7
7
|
export declare class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
@@ -14,6 +14,8 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
14
14
|
'null::timestamp with time zone': string[];
|
|
15
15
|
'null::timestamp without time zone': string[];
|
|
16
16
|
};
|
|
17
|
+
private static readonly PARTIAL_WHERE_RE;
|
|
18
|
+
private static readonly FUNCTIONAL_COL_RE;
|
|
17
19
|
getSchemaBeginning(charset: string, disableForeignKeys?: boolean): string;
|
|
18
20
|
getCreateDatabaseSQL(name: string): string;
|
|
19
21
|
getListTablesSQL(): string;
|
|
@@ -23,11 +25,22 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
23
25
|
getListMaterializedViewsSQL(): string;
|
|
24
26
|
loadMaterializedViews(schema: DatabaseSchema, connection: AbstractSqlConnection, schemaName?: string, ctx?: Transaction): Promise<void>;
|
|
25
27
|
createMaterializedView(name: string, schema: string | undefined, definition: string, withData?: boolean): string;
|
|
28
|
+
createTable(table: DatabaseTable, alter?: boolean): string[];
|
|
26
29
|
dropMaterializedViewIfExists(name: string, schema?: string): string;
|
|
27
30
|
refreshMaterializedView(name: string, schema?: string, concurrently?: boolean): string;
|
|
28
31
|
getNamespaces(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string[]>;
|
|
29
32
|
private getIgnoredNamespacesConditionSQL;
|
|
30
33
|
loadInformationSchema(schema: DatabaseSchema, connection: AbstractSqlConnection, tables: Table[], schemas?: string[], ctx?: Transaction): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Introspects direct partitions only: the `pg_inherits` join surfaces a parent's children but
|
|
36
|
+
* does not recurse into sub-partitioning (e.g. hash-of-range). Declarative `partitionBy`
|
|
37
|
+
* metadata does not express multi-level partitioning either, so grandchildren are intentionally
|
|
38
|
+
* invisible to schema diffing.
|
|
39
|
+
*
|
|
40
|
+
* Entries with an undefined schema bucket are resolved against `current_schema()` so they do
|
|
41
|
+
* not match same-named tables in unrelated schemas.
|
|
42
|
+
*/
|
|
43
|
+
getPartitions(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>, ctx?: Transaction): Promise<Dictionary<TablePartitioning>>;
|
|
31
44
|
getAllIndexes(connection: AbstractSqlConnection, tables: Table[], ctx?: Transaction): Promise<Dictionary<IndexDef[]>>;
|
|
32
45
|
/**
|
|
33
46
|
* Parses column definitions from the full CREATE INDEX expression.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DeferMode, EnumType, Type, Utils, } from '@mikro-orm/core';
|
|
2
2
|
import { SchemaHelper } from '../../schema/SchemaHelper.js';
|
|
3
|
+
import { normalizePartitionBound, normalizePartitionDefinition } from '../../schema/partitioning.js';
|
|
3
4
|
/** PostGIS system views that should be automatically ignored */
|
|
4
5
|
const POSTGIS_VIEWS = ['geography_columns', 'geometry_columns'];
|
|
5
6
|
export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
@@ -12,6 +13,8 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
12
13
|
'null::timestamp with time zone': ['null'],
|
|
13
14
|
'null::timestamp without time zone': ['null'],
|
|
14
15
|
};
|
|
16
|
+
static PARTIAL_WHERE_RE = /\swhere\s+(.+)$/is;
|
|
17
|
+
static FUNCTIONAL_COL_RE = /[(): ,"'`]/;
|
|
15
18
|
getSchemaBeginning(charset, disableForeignKeys) {
|
|
16
19
|
if (disableForeignKeys) {
|
|
17
20
|
return `set names '${charset}';\n${this.disableForeignKeysSQL()}\n\n`;
|
|
@@ -22,13 +25,22 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
22
25
|
return `create database ${this.quote(name)}`;
|
|
23
26
|
}
|
|
24
27
|
getListTablesSQL() {
|
|
28
|
+
// The `pg_inherits` anti-join compares on (schema, table) pairs so cross-schema child
|
|
29
|
+
// partitions are excluded even when their schema is not on the session `search_path`
|
|
30
|
+
// (in which case `inhrelid::regclass::text` renders as `schema.name` rather than bare `name`,
|
|
31
|
+
// breaking a plain `table_name not in (...)` predicate).
|
|
25
32
|
return (`select table_name, table_schema as schema_name, ` +
|
|
26
33
|
`(select pg_catalog.obj_description(c.oid) from pg_catalog.pg_class c
|
|
27
34
|
where c.oid = (select ('"' || table_schema || '"."' || table_name || '"')::regclass::oid) and c.relname = table_name) as table_comment ` +
|
|
28
|
-
`from information_schema.tables ` +
|
|
35
|
+
`from information_schema.tables t ` +
|
|
29
36
|
`where ${this.getIgnoredNamespacesConditionSQL('table_schema')} ` +
|
|
30
37
|
`and table_name != 'geometry_columns' and table_name != 'spatial_ref_sys' and table_type != 'VIEW' ` +
|
|
31
|
-
`and
|
|
38
|
+
`and not exists (` +
|
|
39
|
+
`select 1 from pg_inherits i ` +
|
|
40
|
+
`join pg_class c on c.oid = i.inhrelid ` +
|
|
41
|
+
`join pg_namespace n on n.oid = c.relnamespace ` +
|
|
42
|
+
`where c.relname = t.table_name and n.nspname = t.table_schema` +
|
|
43
|
+
`) ` +
|
|
32
44
|
`order by table_name`);
|
|
33
45
|
}
|
|
34
46
|
getIgnoredViewsCondition() {
|
|
@@ -79,6 +91,23 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
79
91
|
const dataClause = withData ? ' with data' : ' with no data';
|
|
80
92
|
return `create materialized view ${viewName} as ${definition}${dataClause}`;
|
|
81
93
|
}
|
|
94
|
+
createTable(table, alter) {
|
|
95
|
+
const partitioning = table.getPartitioning();
|
|
96
|
+
if (!partitioning) {
|
|
97
|
+
return super.createTable(table, alter);
|
|
98
|
+
}
|
|
99
|
+
const [createTable, ...rest] = super.createTable(table, alter);
|
|
100
|
+
const partitions = partitioning.partitions.map(partition => {
|
|
101
|
+
const partitionName = this.quote(this.getTableName(partition.name, partition.schema ?? table.schema));
|
|
102
|
+
return `create table ${partitionName} partition of ${table.getQuotedName()} ${partition.bound}`;
|
|
103
|
+
});
|
|
104
|
+
// SchemaHelper.append() always terminates the CREATE TABLE with `;`; we rely on that to splice
|
|
105
|
+
// the `partition by ...` clause in before the terminator. Use slice instead of replace() so that
|
|
106
|
+
// regex replacement tokens like `$$`, `$&`, or `$1` inside user-supplied expressions (e.g., a
|
|
107
|
+
// callback that returns a dollar-quoted literal) are not interpreted as back-references.
|
|
108
|
+
const spliced = `${createTable.slice(0, -1)} partition by ${partitioning.definition};`;
|
|
109
|
+
return [spliced, ...rest, ...partitions];
|
|
110
|
+
}
|
|
82
111
|
dropMaterializedViewIfExists(name, schema) {
|
|
83
112
|
return `drop materialized view if exists ${this.quote(this.getTableName(name, schema))} cascade`;
|
|
84
113
|
}
|
|
@@ -118,6 +147,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
118
147
|
const indexes = await this.getAllIndexes(connection, tables, ctx);
|
|
119
148
|
const checks = await this.getAllChecks(connection, tablesBySchema, ctx);
|
|
120
149
|
const fks = await this.getAllForeignKeys(connection, tablesBySchema, ctx);
|
|
150
|
+
const partitionings = await this.getPartitions(connection, tablesBySchema, ctx);
|
|
121
151
|
const triggers = await this.getAllTriggers(connection, tablesBySchema);
|
|
122
152
|
for (const t of tables) {
|
|
123
153
|
const key = this.getTableKey(t);
|
|
@@ -130,8 +160,61 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
130
160
|
if (triggers[key]) {
|
|
131
161
|
table.setTriggers(triggers[key]);
|
|
132
162
|
}
|
|
163
|
+
table.setPartitioning(partitionings[key]);
|
|
133
164
|
}
|
|
134
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Introspects direct partitions only: the `pg_inherits` join surfaces a parent's children but
|
|
168
|
+
* does not recurse into sub-partitioning (e.g. hash-of-range). Declarative `partitionBy`
|
|
169
|
+
* metadata does not express multi-level partitioning either, so grandchildren are intentionally
|
|
170
|
+
* invisible to schema diffing.
|
|
171
|
+
*
|
|
172
|
+
* Entries with an undefined schema bucket are resolved against `current_schema()` so they do
|
|
173
|
+
* not match same-named tables in unrelated schemas.
|
|
174
|
+
*/
|
|
175
|
+
async getPartitions(connection, tablesBySchemas, ctx) {
|
|
176
|
+
// Collapse every (schema, table) pair into a single `values (...)` relation and join against
|
|
177
|
+
// the catalog, instead of building an OR-tree of per-schema `in (...)` predicates. This keeps
|
|
178
|
+
// the query size O(pairs) rather than O(schemas × predicate_overhead) and stays sargable when
|
|
179
|
+
// many schemas are in play.
|
|
180
|
+
const pairs = [...tablesBySchemas.entries()].flatMap(([schema, tables]) => tables.map(t => {
|
|
181
|
+
const schemaLiteral = schema == null ? 'null::text' : `${this.platform.quoteValue(schema)}::text`;
|
|
182
|
+
return `(${schemaLiteral}, ${this.platform.quoteValue(t.table_name)})`;
|
|
183
|
+
}));
|
|
184
|
+
if (pairs.length === 0) {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
const sql = `with targets(schema_name, table_name) as (values ${pairs.join(', ')})
|
|
188
|
+
select parent_ns.nspname as schema_name,
|
|
189
|
+
parent.relname as table_name,
|
|
190
|
+
pg_get_partkeydef(parent.oid) as partition_definition,
|
|
191
|
+
child_ns.nspname as partition_schema_name,
|
|
192
|
+
child.relname as partition_name,
|
|
193
|
+
pg_get_expr(child.relpartbound, child.oid) as partition_bound
|
|
194
|
+
from targets
|
|
195
|
+
join pg_class parent on parent.relname = targets.table_name
|
|
196
|
+
join pg_namespace parent_ns on parent_ns.oid = parent.relnamespace
|
|
197
|
+
and parent_ns.nspname = coalesce(targets.schema_name, current_schema())
|
|
198
|
+
join pg_partitioned_table partitioned on partitioned.partrelid = parent.oid
|
|
199
|
+
left join pg_inherits inherits on inherits.inhparent = parent.oid
|
|
200
|
+
left join pg_class child on child.oid = inherits.inhrelid
|
|
201
|
+
left join pg_namespace child_ns on child_ns.oid = child.relnamespace
|
|
202
|
+
order by parent_ns.nspname, parent.relname, child_ns.nspname, child.relname`;
|
|
203
|
+
const rows = await connection.execute(sql, [], 'all', ctx);
|
|
204
|
+
const ret = {};
|
|
205
|
+
for (const row of rows) {
|
|
206
|
+
const key = this.getTableKey(row);
|
|
207
|
+
ret[key] ??= { definition: normalizePartitionDefinition(row.partition_definition), partitions: [] };
|
|
208
|
+
if (row.partition_name && row.partition_bound) {
|
|
209
|
+
ret[key].partitions.push({
|
|
210
|
+
name: row.partition_name,
|
|
211
|
+
schema: row.partition_schema_name,
|
|
212
|
+
bound: normalizePartitionBound(row.partition_bound),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return ret;
|
|
217
|
+
}
|
|
135
218
|
async getAllIndexes(connection, tables, ctx) {
|
|
136
219
|
const sql = this.getIndexesSQL(tables);
|
|
137
220
|
const unquote = (str) => str.replace(/['"`]/g, '');
|
|
@@ -165,9 +248,22 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
165
248
|
if (index.condeferrable) {
|
|
166
249
|
indexDef.deferMode = index.condeferred ? DeferMode.INITIALLY_DEFERRED : DeferMode.INITIALLY_IMMEDIATE;
|
|
167
250
|
}
|
|
168
|
-
|
|
251
|
+
const hasFunctionalColumns = index.index_def.some((col) => PostgreSqlSchemaHelper.FUNCTIONAL_COL_RE.exec(col));
|
|
252
|
+
const whereMatch = hasFunctionalColumns
|
|
253
|
+
? null
|
|
254
|
+
: PostgreSqlSchemaHelper.PARTIAL_WHERE_RE.exec(index.expression ?? '');
|
|
255
|
+
if (hasFunctionalColumns) {
|
|
256
|
+
// Functional-column expression can't be diffed structurally — keep the whole CREATE
|
|
257
|
+
// statement (WHERE included) on `expression`; don't try to split the predicate.
|
|
169
258
|
indexDef.expression = index.expression;
|
|
170
259
|
}
|
|
260
|
+
else if (whereMatch) {
|
|
261
|
+
let where = whereMatch[1].trim();
|
|
262
|
+
if (where.startsWith('(') && where.endsWith(')') && this.isBalancedWrap(where)) {
|
|
263
|
+
where = where.slice(1, -1).trim();
|
|
264
|
+
}
|
|
265
|
+
indexDef.where = where;
|
|
266
|
+
}
|
|
171
267
|
if (index.deferrable) {
|
|
172
268
|
indexDef.deferMode = index.initially_deferred ? DeferMode.INITIALLY_DEFERRED : DeferMode.INITIALLY_IMMEDIATE;
|
|
173
269
|
}
|
|
@@ -641,6 +737,12 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
|
|
|
641
737
|
return col.join(' ');
|
|
642
738
|
}
|
|
643
739
|
getPreAlterTable(tableDiff, safe) {
|
|
740
|
+
if (tableDiff.changedPartitioning) {
|
|
741
|
+
const from = tableDiff.changedPartitioning.from?.definition;
|
|
742
|
+
const to = tableDiff.changedPartitioning.to?.definition;
|
|
743
|
+
const action = !from ? 'Adding' : !to ? 'Removing' : 'Changing';
|
|
744
|
+
throw new Error(`${action} partition definitions for existing PostgreSQL tables is not supported automatically (${tableDiff.name}: '${from ?? '<none>'}' -> '${to ?? '<none>'}'); create a manual migration instead`);
|
|
745
|
+
}
|
|
644
746
|
const ret = [];
|
|
645
747
|
const parts = tableDiff.name.split('.');
|
|
646
748
|
const tableName = parts.pop();
|
|
@@ -5,6 +5,7 @@ import type { Column, IndexDef, Table, TableDifference, SqlTriggerDef } from '..
|
|
|
5
5
|
import type { DatabaseTable } from '../../schema/DatabaseTable.js';
|
|
6
6
|
import type { DatabaseSchema } from '../../schema/DatabaseSchema.js';
|
|
7
7
|
export declare class SqliteSchemaHelper extends SchemaHelper {
|
|
8
|
+
private static readonly PARTIAL_WHERE_RE;
|
|
8
9
|
disableForeignKeysSQL(): string;
|
|
9
10
|
enableForeignKeysSQL(): string;
|
|
10
11
|
supportsSchemaConstraints(): boolean;
|
|
@@ -15,6 +15,7 @@ const SPATIALITE_VIEWS = [
|
|
|
15
15
|
'ElementaryGeometries',
|
|
16
16
|
];
|
|
17
17
|
export class SqliteSchemaHelper extends SchemaHelper {
|
|
18
|
+
static PARTIAL_WHERE_RE = /\swhere\s+(.+?)\s*$/is;
|
|
18
19
|
disableForeignKeysSQL() {
|
|
19
20
|
return 'pragma foreign_keys = off;';
|
|
20
21
|
}
|
|
@@ -216,10 +217,10 @@ export class SqliteSchemaHelper extends SchemaHelper {
|
|
|
216
217
|
if (index.columnNames.some(column => column.includes('.'))) {
|
|
217
218
|
// JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
|
|
218
219
|
const columns = this.platform.getJsonIndexDefinition(index);
|
|
219
|
-
return `${sqlPrefix} (${columns.join(', ')})`;
|
|
220
|
+
return `${sqlPrefix} (${columns.join(', ')})${this.getIndexWhereClause(index)}`;
|
|
220
221
|
}
|
|
221
222
|
// Use getIndexColumns to support advanced options like sort order and collation
|
|
222
|
-
return `${sqlPrefix} (${this.getIndexColumns(index)})`;
|
|
223
|
+
return `${sqlPrefix} (${this.getIndexColumns(index)})${this.getIndexWhereClause(index)}`;
|
|
223
224
|
}
|
|
224
225
|
parseTableDefinition(sql, cols) {
|
|
225
226
|
const columns = {};
|
|
@@ -355,6 +356,16 @@ export class SqliteSchemaHelper extends SchemaHelper {
|
|
|
355
356
|
const sql = `pragma ${prefix}table_info(\`${tableName}\`)`;
|
|
356
357
|
const cols = await connection.execute(sql, [], 'all', ctx);
|
|
357
358
|
const indexes = await connection.execute(`pragma ${prefix}index_list(\`${tableName}\`)`, [], 'all', ctx);
|
|
359
|
+
// sqlite_master.sql holds the original CREATE INDEX statement — the only place a partial
|
|
360
|
+
// index's WHERE predicate is preserved (PRAGMA index_* don't expose it).
|
|
361
|
+
const indexSqls = await connection.execute(`select name, sql from ${prefix}sqlite_master where type = 'index' and tbl_name = ?`, [tableName], 'all', ctx);
|
|
362
|
+
const wherePredicates = new Map();
|
|
363
|
+
for (const row of indexSqls) {
|
|
364
|
+
const match = row.sql && SqliteSchemaHelper.PARTIAL_WHERE_RE.exec(row.sql);
|
|
365
|
+
if (match) {
|
|
366
|
+
wherePredicates.set(row.name, match[1].trim());
|
|
367
|
+
}
|
|
368
|
+
}
|
|
358
369
|
const ret = [];
|
|
359
370
|
for (const col of cols.filter(c => c.pk)) {
|
|
360
371
|
ret.push({
|
|
@@ -367,12 +378,14 @@ export class SqliteSchemaHelper extends SchemaHelper {
|
|
|
367
378
|
}
|
|
368
379
|
for (const index of indexes.filter(index => !this.isImplicitIndex(index.name))) {
|
|
369
380
|
const res = await connection.execute(`pragma ${prefix}index_info(\`${index.name}\`)`, [], 'all', ctx);
|
|
381
|
+
const where = wherePredicates.get(index.name);
|
|
370
382
|
ret.push(...res.map(row => ({
|
|
371
383
|
columnNames: [row.name],
|
|
372
384
|
keyName: index.name,
|
|
373
385
|
unique: !!index.unique,
|
|
374
386
|
constraint: !!index.unique,
|
|
375
387
|
primary: false,
|
|
388
|
+
...(where ? { where } : {}),
|
|
376
389
|
})));
|
|
377
390
|
}
|
|
378
391
|
return this.mapIndexes(ret);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikro-orm/sql",
|
|
3
|
-
"version": "7.1.0-dev.
|
|
3
|
+
"version": "7.1.0-dev.16",
|
|
4
4
|
"description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"data-mapper",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"@mikro-orm/core": "^7.0.11"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@mikro-orm/core": "7.1.0-dev.
|
|
56
|
+
"@mikro-orm/core": "7.1.0-dev.16"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">= 22.17.0"
|
package/schema/DatabaseSchema.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReferenceKind, isRaw, } from '@mikro-orm/core';
|
|
2
2
|
import { DatabaseTable } from './DatabaseTable.js';
|
|
3
|
+
import { getTablePartitioning } from './partitioning.js';
|
|
3
4
|
/**
|
|
4
5
|
* @internal
|
|
5
6
|
*/
|
|
@@ -173,6 +174,9 @@ export class DatabaseSchema {
|
|
|
173
174
|
}
|
|
174
175
|
const table = schema.addTable(meta.collection, this.getSchemaName(meta, config, schemaName));
|
|
175
176
|
table.comment = meta.comment;
|
|
177
|
+
if (meta.partitionBy) {
|
|
178
|
+
table.setPartitioning(getTablePartitioning(meta, this.getSchemaName(meta, config, schemaName), id => platform.quoteIdentifier(id)));
|
|
179
|
+
}
|
|
176
180
|
// For TPT child entities, only use ownProps (properties defined in this entity only)
|
|
177
181
|
// For all other entities (including TPT root), use all props
|
|
178
182
|
const propsToProcess = meta.inheritanceType === 'tpt' && meta.tptParent && meta.ownProps ? meta.ownProps : meta.props;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Configuration, type DeferMode, type Dictionary, type EntityMetadata, type EntityProperty, type IndexCallback, type NamingStrategy } from '@mikro-orm/core';
|
|
2
2
|
import type { SchemaHelper } from './SchemaHelper.js';
|
|
3
|
-
import type { CheckDef, Column, ForeignKey, IndexDef, SqlTriggerDef } from '../typings.js';
|
|
3
|
+
import type { CheckDef, Column, ForeignKey, IndexDef, TablePartitioning, SqlTriggerDef } from '../typings.js';
|
|
4
4
|
import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
|
|
5
5
|
/**
|
|
6
6
|
* @internal
|
|
@@ -15,6 +15,7 @@ export declare class DatabaseTable {
|
|
|
15
15
|
items: string[];
|
|
16
16
|
}>;
|
|
17
17
|
comment?: string;
|
|
18
|
+
partitioning?: TablePartitioning;
|
|
18
19
|
constructor(platform: AbstractSqlPlatform, name: string, schema?: string | undefined);
|
|
19
20
|
getQuotedName(): string;
|
|
20
21
|
getColumns(): Column[];
|
|
@@ -22,6 +23,9 @@ export declare class DatabaseTable {
|
|
|
22
23
|
removeColumn(name: string): void;
|
|
23
24
|
getIndexes(): IndexDef[];
|
|
24
25
|
getChecks(): CheckDef[];
|
|
26
|
+
getPartitioning(): TablePartitioning | undefined;
|
|
27
|
+
/** @internal */
|
|
28
|
+
setPartitioning(partitioning?: TablePartitioning): void;
|
|
25
29
|
getTriggers(): SqlTriggerDef[];
|
|
26
30
|
/** @internal */
|
|
27
31
|
setIndexes(indexes: IndexDef[]): void;
|
|
@@ -67,6 +71,7 @@ export declare class DatabaseTable {
|
|
|
67
71
|
name?: string;
|
|
68
72
|
type?: string;
|
|
69
73
|
expression?: string | IndexCallback<any>;
|
|
74
|
+
where?: string | Dictionary;
|
|
70
75
|
deferMode?: DeferMode | `${DeferMode}`;
|
|
71
76
|
options?: Dictionary;
|
|
72
77
|
columns?: {
|
|
@@ -82,6 +87,7 @@ export declare class DatabaseTable {
|
|
|
82
87
|
disabled?: boolean;
|
|
83
88
|
clustered?: boolean;
|
|
84
89
|
}, type: 'index' | 'unique' | 'primary'): void;
|
|
90
|
+
private processIndexWhere;
|
|
85
91
|
addCheck(check: CheckDef): void;
|
|
86
92
|
addTrigger(trigger: SqlTriggerDef): void;
|
|
87
93
|
toJSON(): Dictionary;
|
package/schema/DatabaseTable.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DecimalType, EntitySchema, isRaw, ReferenceKind, t, Type, UnknownType, Utils, } from '@mikro-orm/core';
|
|
2
|
+
import { toEntityPartitionBy } from './partitioning.js';
|
|
2
3
|
/**
|
|
3
4
|
* @internal
|
|
4
5
|
*/
|
|
@@ -13,6 +14,7 @@ export class DatabaseTable {
|
|
|
13
14
|
#platform;
|
|
14
15
|
nativeEnums = {}; // for postgres
|
|
15
16
|
comment;
|
|
17
|
+
partitioning;
|
|
16
18
|
constructor(platform, name, schema) {
|
|
17
19
|
this.name = name;
|
|
18
20
|
this.schema = schema;
|
|
@@ -36,6 +38,13 @@ export class DatabaseTable {
|
|
|
36
38
|
getChecks() {
|
|
37
39
|
return this.#checks;
|
|
38
40
|
}
|
|
41
|
+
getPartitioning() {
|
|
42
|
+
return this.partitioning;
|
|
43
|
+
}
|
|
44
|
+
/** @internal */
|
|
45
|
+
setPartitioning(partitioning) {
|
|
46
|
+
this.partitioning = partitioning;
|
|
47
|
+
}
|
|
39
48
|
getTriggers() {
|
|
40
49
|
return this.#triggers;
|
|
41
50
|
}
|
|
@@ -185,6 +194,7 @@ export class DatabaseTable {
|
|
|
185
194
|
const { fksOnColumnProps, fksOnStandaloneProps, columnFks, fkIndexes, nullableForeignKeys, skippedColumnNames } = this.foreignKeysToProps(namingStrategy, scalarPropertiesForRelations);
|
|
186
195
|
const name = namingStrategy.getEntityName(this.name, this.schema);
|
|
187
196
|
const schema = new EntitySchema({ name, collection: this.name, schema: this.schema, comment: this.comment });
|
|
197
|
+
schema.meta.partitionBy = toEntityPartitionBy(this.partitioning, this.name, this.schema);
|
|
188
198
|
const compositeFkIndexes = {};
|
|
189
199
|
const compositeFkUniques = {};
|
|
190
200
|
const potentiallyUnmappedIndexes = this.#indexes.filter(index => !index.primary && // Skip primary index. Whether it's in use by scalar column or FK, it's already mapped.
|
|
@@ -204,6 +214,7 @@ export class DatabaseTable {
|
|
|
204
214
|
name: index.keyName,
|
|
205
215
|
deferMode: index.deferMode,
|
|
206
216
|
expression: index.expression,
|
|
217
|
+
where: index.where,
|
|
207
218
|
// Advanced index options - convert column names to property names
|
|
208
219
|
columns: index.columns?.map(col => ({
|
|
209
220
|
...col,
|
|
@@ -234,7 +245,7 @@ export class DatabaseTable {
|
|
|
234
245
|
index.invisible ||
|
|
235
246
|
index.disabled ||
|
|
236
247
|
index.clustered;
|
|
237
|
-
const isTrivial = !index.deferMode && !index.expression && !hasAdvancedOptions;
|
|
248
|
+
const isTrivial = !index.deferMode && !index.expression && !index.where && !hasAdvancedOptions;
|
|
238
249
|
if (isTrivial) {
|
|
239
250
|
// Index is for FK. Map to the FK prop and move on.
|
|
240
251
|
const fkForIndex = fkIndexes.get(index);
|
|
@@ -876,16 +887,25 @@ export class DatabaseTable {
|
|
|
876
887
|
if (index.fillFactor != null && (index.fillFactor < 0 || index.fillFactor > 100)) {
|
|
877
888
|
throw new Error(`fillFactor must be between 0 and 100, got ${index.fillFactor} for index '${name}' on entity '${meta.className}'`);
|
|
878
889
|
}
|
|
890
|
+
// The `expression` escape hatch takes the full index definition as raw SQL; combining it
|
|
891
|
+
// with `where` is dialect-dependent (PG/Oracle/MySQL drop `where`, MSSQL appends it) so we
|
|
892
|
+
// reject the combination up-front and ask users to inline the predicate into `expression`.
|
|
893
|
+
if (index.expression && index.where != null) {
|
|
894
|
+
throw new Error(`Index '${name}' on entity '${meta.className}': cannot combine \`expression\` with \`where\` — inline the WHERE clause into the \`expression\` escape hatch, or drop \`expression\` and use structured \`properties\` + \`where\`.`);
|
|
895
|
+
}
|
|
896
|
+
const where = this.processIndexWhere(index.where, meta);
|
|
879
897
|
this.#indexes.push({
|
|
880
898
|
keyName: name,
|
|
881
899
|
columnNames: properties,
|
|
882
900
|
composite: properties.length > 1,
|
|
883
|
-
// JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
|
|
884
|
-
|
|
901
|
+
// JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them.
|
|
902
|
+
// Partial indexes (`where`) must use CREATE [UNIQUE] INDEX form — constraints can't carry predicates.
|
|
903
|
+
constraint: type !== 'index' && !properties.some((d) => d.includes('.')) && !where,
|
|
885
904
|
primary: type === 'primary',
|
|
886
905
|
unique: type !== 'index',
|
|
887
906
|
type: index.type,
|
|
888
907
|
expression: this.processIndexExpression(name, index.expression, meta),
|
|
908
|
+
where,
|
|
889
909
|
options: index.options,
|
|
890
910
|
deferMode: index.deferMode,
|
|
891
911
|
columns,
|
|
@@ -896,6 +916,15 @@ export class DatabaseTable {
|
|
|
896
916
|
clustered: index.clustered,
|
|
897
917
|
});
|
|
898
918
|
}
|
|
919
|
+
processIndexWhere(where, meta) {
|
|
920
|
+
if (where == null) {
|
|
921
|
+
return undefined;
|
|
922
|
+
}
|
|
923
|
+
// The driver is always an `AbstractSqlDriver` here — `DatabaseTable` is only instantiated
|
|
924
|
+
// by SQL-side schema code, so the platform's config-bound driver is guaranteed to be one.
|
|
925
|
+
const driver = this.#platform.getConfig().getDriver();
|
|
926
|
+
return driver.renderPartialIndexWhere(meta.class, where);
|
|
927
|
+
}
|
|
899
928
|
addCheck(check) {
|
|
900
929
|
this.#checks.push(check);
|
|
901
930
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ArrayType, BooleanType, DateTimeType, inspect, JsonType, parseJsonSafe, Utils, } from '@mikro-orm/core';
|
|
2
2
|
import { DatabaseTable } from './DatabaseTable.js';
|
|
3
|
+
import { diffPartitioning } from './partitioning.js';
|
|
3
4
|
/**
|
|
4
5
|
* Compares two Schemas and return an instance of SchemaDifference.
|
|
5
6
|
*/
|
|
@@ -200,6 +201,17 @@ export class SchemaComparator {
|
|
|
200
201
|
});
|
|
201
202
|
changes++;
|
|
202
203
|
}
|
|
204
|
+
if (diffPartitioning(fromTable.getPartitioning(), toTable.getPartitioning(), this.#platform.getDefaultSchemaName())) {
|
|
205
|
+
tableDifferences.changedPartitioning = {
|
|
206
|
+
from: fromTable.getPartitioning(),
|
|
207
|
+
to: toTable.getPartitioning(),
|
|
208
|
+
};
|
|
209
|
+
this.log(`table partitioning changed for ${tableDifferences.name}`, {
|
|
210
|
+
fromPartitioning: fromTable.getPartitioning(),
|
|
211
|
+
toPartitioning: toTable.getPartitioning(),
|
|
212
|
+
});
|
|
213
|
+
changes++;
|
|
214
|
+
}
|
|
203
215
|
const fromTableColumns = fromTable.getColumns();
|
|
204
216
|
const toTableColumns = toTable.getColumns();
|
|
205
217
|
// See if all the columns in "from" table exist in "to" table
|
|
@@ -266,6 +278,19 @@ export class SchemaComparator {
|
|
|
266
278
|
if (!this.diffIndex(index, toTableIndex)) {
|
|
267
279
|
continue;
|
|
268
280
|
}
|
|
281
|
+
// Constraint-vs-index form mismatch needs drop+create with the OLD form's drop SQL,
|
|
282
|
+
// which `changedIndexes` (uses new form only) can't do. Primary keys stay on the
|
|
283
|
+
// changed path which emits `add primary key`.
|
|
284
|
+
if (!index.primary && !!index.constraint !== !!toTableIndex.constraint) {
|
|
285
|
+
tableDifferences.removedIndexes[index.keyName] = index;
|
|
286
|
+
tableDifferences.addedIndexes[index.keyName] = toTableIndex;
|
|
287
|
+
this.log(`index ${index.keyName} changed form in table ${tableDifferences.name}`, {
|
|
288
|
+
fromTableIndex: index,
|
|
289
|
+
toTableIndex,
|
|
290
|
+
});
|
|
291
|
+
changes += 2;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
269
294
|
tableDifferences.changedIndexes[index.keyName] = toTableIndex;
|
|
270
295
|
this.log(`index ${index.keyName} changed in table ${tableDifferences.name}`, {
|
|
271
296
|
fromTableIndex: index,
|
|
@@ -572,7 +597,8 @@ export class SchemaComparator {
|
|
|
572
597
|
* Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
|
|
573
598
|
*/
|
|
574
599
|
diffIndex(index1, index2) {
|
|
575
|
-
//
|
|
600
|
+
// Opaque raw expressions (`expression` escape hatch) and full-text indexes can't be
|
|
601
|
+
// compared structurally — fall back to name-only matching.
|
|
576
602
|
if (index1.expression || index2.expression || index1.type === 'fulltext' || index2.type === 'fulltext') {
|
|
577
603
|
return index1.keyName !== index2.keyName;
|
|
578
604
|
}
|
|
@@ -623,6 +649,11 @@ export class SchemaComparator {
|
|
|
623
649
|
if (!!index1.clustered !== !!index2.clustered) {
|
|
624
650
|
return false;
|
|
625
651
|
}
|
|
652
|
+
// Compare WHERE predicate of partial indexes structurally (whitespace/quoting/casing
|
|
653
|
+
// are normalized via the same helper used for check constraints).
|
|
654
|
+
if (this.diffExpression(index1.where ?? '', index2.where ?? '')) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
626
657
|
if (!index1.unique && !index1.primary) {
|
|
627
658
|
// this is a special case: If the current key is neither primary or unique, any unique or
|
|
628
659
|
// primary key will always have the same effect for the index and there cannot be any constraint
|
package/schema/SchemaHelper.d.ts
CHANGED
|
@@ -41,6 +41,46 @@ export declare abstract class SchemaHelper {
|
|
|
41
41
|
* Hook for adding driver-specific index options (e.g., fill factor for PostgreSQL).
|
|
42
42
|
*/
|
|
43
43
|
protected getCreateIndexSuffix(_index: IndexDef): string;
|
|
44
|
+
/**
|
|
45
|
+
* Default emits ` where <predicate>` for partial indexes. Only Oracle overrides this to
|
|
46
|
+
* return `''` (it emulates partials via CASE-WHEN columns). MySQL sidesteps the whole path
|
|
47
|
+
* with its own `getCreateIndexSQL` that never calls this, and MariaDB refuses the feature
|
|
48
|
+
* entirely via an override on `getIndexColumns`.
|
|
49
|
+
*/
|
|
50
|
+
protected getIndexWhereClause(index: IndexDef): string;
|
|
51
|
+
/**
|
|
52
|
+
* Wraps each indexed column in `(CASE WHEN <predicate> THEN <col> END)` for dialects that
|
|
53
|
+
* emulate partial indexes via functional indexes (MySQL/MariaDB/Oracle). Combined with NULL
|
|
54
|
+
* being treated as distinct in unique indexes, this enforces uniqueness only where the
|
|
55
|
+
* predicate holds. Throws if combined with the advanced `columns` option.
|
|
56
|
+
*/
|
|
57
|
+
protected emulatePartialIndexColumns(index: IndexDef): string;
|
|
58
|
+
/**
|
|
59
|
+
* Strips `<col> IS NOT NULL` clauses (with the dialect's identifier quoting) from an
|
|
60
|
+
* introspected partial-index predicate when the column matches one of the index's own
|
|
61
|
+
* columns. MikroORM auto-emits this guard for unique indexes on nullable columns
|
|
62
|
+
* (MSSQL, Oracle) — it's an internal artifact, not user intent.
|
|
63
|
+
*
|
|
64
|
+
* Strips at most one guard per column (the tail-most occurrence), matching how MikroORM
|
|
65
|
+
* appends a single guard per index column. This preserves user intent when they redundantly
|
|
66
|
+
* include the same `<col> IS NOT NULL` in their predicate — the guard we added is removed,
|
|
67
|
+
* their copy survives.
|
|
68
|
+
*/
|
|
69
|
+
protected stripAutoNotNullFilter(filterDef: string, columnNames: string[], identifierPattern: RegExp): string;
|
|
70
|
+
/**
|
|
71
|
+
* Whether `[…]` is a quoted identifier (MSSQL convention). Other dialects either reuse
|
|
72
|
+
* `[` for array literals/constructors or never produce it in introspected predicates,
|
|
73
|
+
* so the default is `false` and the MSSQL helper opts in.
|
|
74
|
+
*/
|
|
75
|
+
protected get bracketQuotedIdentifiers(): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Splits on top-level ` AND ` (case-insensitive), ignoring matches that sit inside string
|
|
78
|
+
* literals, quoted identifiers, or parenthesized groups — so a predicate like
|
|
79
|
+
* `'foo AND bar' = col` or `(a AND b) OR c` is not mis-split.
|
|
80
|
+
*/
|
|
81
|
+
protected splitTopLevelAnd(s: string): string[];
|
|
82
|
+
/** Returns true iff the leading `(` matches the trailing `)` (i.e. they wrap the whole string). */
|
|
83
|
+
protected isBalancedWrap(s: string): boolean;
|
|
44
84
|
/**
|
|
45
85
|
* Build the column list for an index, supporting advanced options like sort order, nulls ordering, and collation.
|
|
46
86
|
* Note: Prefix length is only supported by MySQL/MariaDB which override this method.
|
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.
|
|
@@ -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;
|
|
@@ -118,6 +123,15 @@ export interface SqlTriggerDef {
|
|
|
118
123
|
when?: string;
|
|
119
124
|
expression?: string;
|
|
120
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
|
+
}
|
|
121
135
|
export interface ColumnDifference {
|
|
122
136
|
oldColumnName: string;
|
|
123
137
|
column: Column;
|
|
@@ -127,6 +141,10 @@ export interface ColumnDifference {
|
|
|
127
141
|
export interface TableDifference {
|
|
128
142
|
name: string;
|
|
129
143
|
changedComment?: string;
|
|
144
|
+
changedPartitioning?: {
|
|
145
|
+
from?: TablePartitioning;
|
|
146
|
+
to?: TablePartitioning;
|
|
147
|
+
};
|
|
130
148
|
fromTable: DatabaseTable;
|
|
131
149
|
toTable: DatabaseTable;
|
|
132
150
|
addedColumns: Dictionary<Column>;
|