@mikro-orm/sql 7.1.0-dev.20 → 7.1.0-dev.22

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.
@@ -50,8 +50,19 @@ export declare abstract class AbstractSqlPlatform extends Platform {
50
50
  * @internal
51
51
  */
52
52
  quoteCollation(collation: string): string;
53
- /** @internal */
54
- protected validateCollationName(collation: string): void;
53
+ /**
54
+ * PG ICU locale names include hyphens (`en-US-x-icu`) and libc locales include dots (`en_US.utf8`),
55
+ * so word-chars alone would reject valid real-world collations.
56
+ * @internal
57
+ */
58
+ validateCollationName(collation: string): void;
59
+ /**
60
+ * Whether collation names compare case-insensitively in this dialect. MySQL/MariaDB, MSSQL, and
61
+ * SQLite use case-insensitive collation identifiers; PostgreSQL stores them as case-sensitive
62
+ * names in `pg_collation` (e.g. `en-US-x-icu` is distinct from `EN-US-X-ICU`).
63
+ * @internal
64
+ */
65
+ caseInsensitiveCollationNames(): boolean;
55
66
  /** @internal */
56
67
  validateJsonPropertyName(name: string): void;
57
68
  /**
@@ -123,12 +123,25 @@ export class AbstractSqlPlatform extends Platform {
123
123
  this.validateCollationName(collation);
124
124
  return this.quoteIdentifier(collation);
125
125
  }
126
- /** @internal */
126
+ /**
127
+ * PG ICU locale names include hyphens (`en-US-x-icu`) and libc locales include dots (`en_US.utf8`),
128
+ * so word-chars alone would reject valid real-world collations.
129
+ * @internal
130
+ */
127
131
  validateCollationName(collation) {
128
- if (!/^[\w]+$/.test(collation)) {
129
- throw new Error(`Invalid collation name: '${collation}'. Collation names must contain only word characters.`);
132
+ if (!/^[\w\-.]+$/.test(collation)) {
133
+ throw new Error(`Invalid collation name: '${collation}'. Collation names must contain only word characters, hyphens, and dots.`);
130
134
  }
131
135
  }
136
+ /**
137
+ * Whether collation names compare case-insensitively in this dialect. MySQL/MariaDB, MSSQL, and
138
+ * SQLite use case-insensitive collation identifiers; PostgreSQL stores them as case-sensitive
139
+ * names in `pg_collation` (e.g. `en-US-x-icu` is distinct from `EN-US-X-ICU`).
140
+ * @internal
141
+ */
142
+ caseInsensitiveCollationNames() {
143
+ return true;
144
+ }
132
145
  /** @internal */
133
146
  validateJsonPropertyName(name) {
134
147
  if (!AbstractSqlPlatform.#JSON_PROPERTY_NAME_RE.test(name)) {
@@ -35,7 +35,7 @@ export class MySqlSchemaHelper extends SchemaHelper {
35
35
  return sql;
36
36
  }
37
37
  getListTablesSQL() {
38
- return `select table_name as table_name, nullif(table_schema, schema()) as schema_name, table_comment as table_comment from information_schema.tables where table_type = 'BASE TABLE' and table_schema = schema()`;
38
+ return `select table_name as table_name, nullif(table_schema, schema()) as schema_name, table_comment as table_comment, table_collation as table_collation from information_schema.tables where table_type = 'BASE TABLE' and table_schema = schema()`;
39
39
  }
40
40
  getListViewsSQL() {
41
41
  return `select table_name as view_name, nullif(table_schema, schema()) as schema_name, view_definition from information_schema.views where table_schema = schema()`;
@@ -72,6 +72,7 @@ export class MySqlSchemaHelper extends SchemaHelper {
72
72
  for (const t of tables) {
73
73
  const key = this.getTableKey(t);
74
74
  const table = schema.addTable(t.table_name, t.schema_name, t.table_comment);
75
+ table.collation = t.table_collation ?? undefined;
75
76
  const pks = await this.getPrimaryKeys(connection, indexes[key], table.name, table.schema);
76
77
  table.init(columns[key], indexes[key], checks[key], pks, fks[key], enums[key]);
77
78
  if (triggers[key]) {
@@ -211,22 +212,25 @@ export class MySqlSchemaHelper extends SchemaHelper {
211
212
  return sql;
212
213
  }
213
214
  async getAllColumns(connection, tables, ctx) {
214
- const sql = `select table_name as table_name,
215
- nullif(table_schema, schema()) as schema_name,
216
- column_name as column_name,
217
- column_default as column_default,
218
- nullif(column_comment, '') as column_comment,
219
- is_nullable as is_nullable,
220
- data_type as data_type,
221
- column_type as column_type,
222
- column_key as column_key,
223
- extra as extra,
224
- generation_expression as generation_expression,
225
- numeric_precision as numeric_precision,
226
- numeric_scale as numeric_scale,
227
- ifnull(datetime_precision, character_maximum_length) length
228
- from information_schema.columns where table_schema = database() and table_name in (${tables.map(t => this.platform.quoteValue(t.table_name))})
229
- order by ordinal_position`;
215
+ const sql = `select c.table_name as table_name,
216
+ nullif(c.table_schema, schema()) as schema_name,
217
+ c.column_name as column_name,
218
+ c.column_default as column_default,
219
+ nullif(c.column_comment, '') as column_comment,
220
+ c.is_nullable as is_nullable,
221
+ c.data_type as data_type,
222
+ c.column_type as column_type,
223
+ c.column_key as column_key,
224
+ c.extra as extra,
225
+ c.generation_expression as generation_expression,
226
+ c.numeric_precision as numeric_precision,
227
+ c.numeric_scale as numeric_scale,
228
+ ifnull(c.datetime_precision, c.character_maximum_length) length,
229
+ nullif(c.collation_name, t.table_collation) as collation_name
230
+ from information_schema.columns c
231
+ join information_schema.tables t on t.table_schema = c.table_schema and t.table_name = c.table_name
232
+ where c.table_schema = database() and c.table_name in (${tables.map(t => this.platform.quoteValue(t.table_name))})
233
+ order by c.ordinal_position`;
230
234
  const allColumns = await connection.execute(sql, [], 'all', ctx);
231
235
  const str = (val) => (val != null ? '' + val : val);
232
236
  const extra = (val) => val.replace(/auto_increment|default_generated|(stored|virtual) generated/i, '').trim() || undefined;
@@ -257,6 +261,7 @@ export class MySqlSchemaHelper extends SchemaHelper {
257
261
  precision: col.numeric_precision,
258
262
  scale: col.numeric_scale,
259
263
  comment: col.column_comment,
264
+ collation: col.collation_name ?? undefined,
260
265
  extra: extra(col.extra),
261
266
  generated,
262
267
  });
@@ -417,10 +422,13 @@ export class MySqlSchemaHelper extends SchemaHelper {
417
422
  const col = this.createTableColumn(column, table, changedProperties);
418
423
  return [`alter table ${table.getQuotedName()} modify ${col}`];
419
424
  }
425
+ // MySQL MODIFY/CHANGE resets omitted column attributes to the table default, so collation must
426
+ // be re-emitted on rename and comment-only paths to preserve a non-default column collation.
420
427
  getColumnDeclarationSQL(col) {
421
428
  let ret = col.type;
422
429
  ret += col.unsigned ? ' unsigned' : '';
423
430
  ret += col.autoincrement ? ' auto_increment' : '';
431
+ ret += col.collation ? ` ${this.getCollateSQL(col.collation)}` : '';
424
432
  ret += ' ';
425
433
  ret += col.nullable ? 'null' : 'not null';
426
434
  ret += col.default ? ' default ' + col.default : '';
@@ -47,6 +47,7 @@ export declare class BasePostgreSqlPlatform extends AbstractSqlPlatform {
47
47
  precision?: number;
48
48
  scale?: number;
49
49
  autoincrement?: boolean;
50
+ columnTypes?: string[];
50
51
  }): string;
51
52
  getMappedType(type: string): Type<unknown>;
52
53
  getRegExpOperator(val?: unknown, flags?: string): string;
@@ -111,4 +112,5 @@ export declare class BasePostgreSqlPlatform extends AbstractSqlPlatform {
111
112
  }[]): string;
112
113
  getJsonArrayElementPropertySQL(alias: string, property: string, type: string): string;
113
114
  getDefaultClientUrl(): string;
115
+ caseInsensitiveCollationNames(): boolean;
114
116
  }
@@ -93,18 +93,27 @@ export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
93
93
  }
94
94
  normalizeColumnType(type, options) {
95
95
  const simpleType = this.extractSimpleType(type);
96
- if (['int', 'int4', 'integer'].includes(simpleType)) {
96
+ if (['int', 'int4', 'integer', 'serial'].includes(simpleType)) {
97
97
  return this.getIntegerTypeDeclarationSQL({});
98
98
  }
99
- if (['bigint', 'int8'].includes(simpleType)) {
99
+ if (['bigint', 'int8', 'bigserial'].includes(simpleType)) {
100
100
  return this.getBigIntTypeDeclarationSQL({});
101
101
  }
102
- if (['smallint', 'int2'].includes(simpleType)) {
102
+ if (['smallint', 'int2', 'smallserial'].includes(simpleType)) {
103
103
  return this.getSmallIntTypeDeclarationSQL({});
104
104
  }
105
105
  if (['boolean', 'bool'].includes(simpleType)) {
106
106
  return this.getBooleanTypeDeclarationSQL();
107
107
  }
108
+ if (['double', 'double precision', 'float8'].includes(simpleType)) {
109
+ return this.getDoubleDeclarationSQL();
110
+ }
111
+ if (['real', 'float4'].includes(simpleType)) {
112
+ return this.getFloatDeclarationSQL();
113
+ }
114
+ if (['timestamptz', 'timestamp with time zone'].includes(simpleType)) {
115
+ return this.getDateTimeTypeDeclarationSQL(options);
116
+ }
108
117
  if (['varchar', 'character varying'].includes(simpleType)) {
109
118
  return this.getVarcharTypeDeclarationSQL(options);
110
119
  }
@@ -117,6 +126,12 @@ export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
117
126
  if (['interval'].includes(simpleType)) {
118
127
  return this.getIntervalTypeDeclarationSQL(options);
119
128
  }
129
+ // TimeType.getColumnType drops the timezone qualifier, so detect tz aliases from the original column type.
130
+ const originalType = options.columnTypes?.[0]?.toLowerCase() ?? type;
131
+ if (/^timetz\b/.test(originalType) || /^time\s+with\s+time\s+zone\b/.test(originalType)) {
132
+ const length = options.length ?? this.getDefaultDateTimeLength();
133
+ return `timetz(${length})`;
134
+ }
120
135
  return super.normalizeColumnType(type, options);
121
136
  }
122
137
  getMappedType(type) {
@@ -365,4 +380,7 @@ export class BasePostgreSqlPlatform extends AbstractSqlPlatform {
365
380
  getDefaultClientUrl() {
366
381
  return 'postgresql://postgres@127.0.0.1:5432';
367
382
  }
383
+ caseInsensitiveCollationNames() {
384
+ return false;
385
+ }
368
386
  }
@@ -67,6 +67,12 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
67
67
  /** Generates SQL to drop a PostgreSQL trigger and its associated function. */
68
68
  dropTrigger(table: DatabaseTable, trigger: SqlTriggerDef): string;
69
69
  private getSchemaQualifiedTriggerFnName;
70
+ /**
71
+ * Resolves the real name of the implicit 'default' collation (the DB's `datcollate`),
72
+ * so the comparator can treat `@Property({ collation: '<datcollate>' })` as equivalent
73
+ * to a column that introspects as using the default.
74
+ */
75
+ getDatabaseCollation(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string | undefined>;
70
76
  getAllTriggers(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>): Promise<Dictionary<SqlTriggerDef[]>>;
71
77
  private getTriggersSQL;
72
78
  getAllForeignKeys(connection: AbstractSqlConnection, tablesBySchemas: Map<string | undefined, Table[]>, ctx?: Transaction): Promise<Dictionary<Dictionary<ForeignKey>>>;
@@ -79,6 +85,7 @@ export declare class PostgreSqlSchemaHelper extends SchemaHelper {
79
85
  getDropNativeEnumSQL(name: string, schema?: string): string;
80
86
  getAlterNativeEnumSQL(name: string, schema?: string, value?: string, items?: string[], oldItems?: string[]): string;
81
87
  private getEnumDefinitions;
88
+ protected getCollateSQL(collation: string): string;
82
89
  createTableColumn(column: Column, table: DatabaseTable): string | undefined;
83
90
  getPreAlterTable(tableDiff: TableDifference, safe: boolean): string[];
84
91
  castColumn(name: string, type: string): string;
@@ -149,9 +149,11 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
149
149
  const fks = await this.getAllForeignKeys(connection, tablesBySchema, ctx);
150
150
  const partitionings = await this.getPartitions(connection, tablesBySchema, ctx);
151
151
  const triggers = await this.getAllTriggers(connection, tablesBySchema);
152
+ const dbCollation = await this.getDatabaseCollation(connection, ctx);
152
153
  for (const t of tables) {
153
154
  const key = this.getTableKey(t);
154
155
  const table = schema.addTable(t.table_name, t.schema_name, t.table_comment);
156
+ table.collation = dbCollation;
155
157
  const pks = await this.getPrimaryKeys(connection, indexes[key], table.name, table.schema);
156
158
  const enums = this.getEnumDefinitions(checks[key] ?? []);
157
159
  if (columns[key]) {
@@ -366,10 +368,12 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
366
368
  is_identity,
367
369
  identity_generation,
368
370
  generation_expression,
369
- pg_catalog.col_description(pgc.oid, cols.ordinal_position::int) column_comment
371
+ pg_catalog.col_description(pgc.oid, cols.ordinal_position::int) column_comment,
372
+ coll.collname as collation_name
370
373
  from information_schema.columns cols
371
374
  join pg_class pgc on cols.table_name = pgc.relname
372
375
  join pg_attribute pga on pgc.oid = pga.attrelid and cols.column_name = pga.attname
376
+ left join pg_collation coll on pga.attcollation = coll.oid and coll.collname <> 'default'
373
377
  where (${[...tablesBySchemas.entries()].map(([schema, tables]) => `(table_schema = ${this.platform.quoteValue(schema)} and table_name in (${tables.map(t => this.platform.quoteValue(t.table_name)).join(',')}))`).join(' or ')})
374
378
  order by ordinal_position`;
375
379
  const allColumns = await connection.execute(sql, [], 'all', ctx);
@@ -419,6 +423,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
419
423
  ? col.generation_expression + ' stored'
420
424
  : undefined,
421
425
  comment: col.column_comment,
426
+ collation: col.collation_name ?? undefined,
422
427
  };
423
428
  let enumKey = column.type;
424
429
  let enumEntry = nativeEnums?.[enumKey];
@@ -504,6 +509,15 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
504
509
  }
505
510
  return this.platform.quoteIdentifier(rawName);
506
511
  }
512
+ /**
513
+ * Resolves the real name of the implicit 'default' collation (the DB's `datcollate`),
514
+ * so the comparator can treat `@Property({ collation: '<datcollate>' })` as equivalent
515
+ * to a column that introspects as using the default.
516
+ */
517
+ async getDatabaseCollation(connection, ctx) {
518
+ const [row] = await connection.execute(`select datcollate as collation from pg_database where datname = current_database()`, [], 'all', ctx);
519
+ return row?.collation;
520
+ }
507
521
  async getAllTriggers(connection, tablesBySchemas) {
508
522
  const sql = this.getTriggersSQL(tablesBySchemas);
509
523
  const allTriggers = await connection.execute(sql);
@@ -694,6 +708,9 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
694
708
  return o;
695
709
  }, {});
696
710
  }
711
+ getCollateSQL(collation) {
712
+ return `collate ${this.platform.quoteCollation(collation)}`;
713
+ }
697
714
  createTableColumn(column, table) {
698
715
  const pk = table.getPrimaryKey();
699
716
  const compositePK = pk?.composite;
@@ -726,6 +743,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
726
743
  columnType += ` generated always as ${column.generated}`;
727
744
  }
728
745
  col.push(columnType);
746
+ Utils.runIfNotEmpty(() => col.push(this.getCollateSQL(column.collation)), column.collation);
729
747
  Utils.runIfNotEmpty(() => col.push('null'), column.nullable);
730
748
  Utils.runIfNotEmpty(() => col.push('not null'), !column.nullable);
731
749
  }
@@ -15,6 +15,7 @@ export declare class SqlitePlatform extends AbstractSqlPlatform {
15
15
  getDateTimeTypeDeclarationSQL(column: {
16
16
  length: number;
17
17
  }): string;
18
+ getDefaultVersionLength(): number;
18
19
  getBeginTransactionSQL(options?: {
19
20
  isolationLevel?: IsolationLevel;
20
21
  readOnly?: boolean;
@@ -24,6 +24,10 @@ export class SqlitePlatform extends AbstractSqlPlatform {
24
24
  getDateTimeTypeDeclarationSQL(column) {
25
25
  return 'datetime';
26
26
  }
27
+ // sqlite's datetime DDL drops precision and the current-ts expression hardcodes ms scaling
28
+ getDefaultVersionLength() {
29
+ return 0;
30
+ }
27
31
  getBeginTransactionSQL(options) {
28
32
  return ['begin'];
29
33
  }
@@ -45,7 +45,8 @@ export declare class SqliteSchemaHelper extends SchemaHelper {
45
45
  * We need to add them back so they match what we generate in DDL.
46
46
  */
47
47
  private wrapExpressionDefault;
48
- private getEnumDefinitions;
48
+ /** Extract enum values from `IN (…)` CHECKs only — a `!= 'x'` check would otherwise be misread as a one-item enum. */
49
+ private extractEnumValuesFromChecks;
49
50
  getPrimaryKeys(connection: AbstractSqlConnection, indexes: IndexDef[], tableName: string, schemaName?: string, ctx?: Transaction): Promise<string[]>;
50
51
  private getIndexes;
51
52
  private getChecks;
@@ -101,7 +101,7 @@ export class SqliteSchemaHelper extends SchemaHelper {
101
101
  const checks = await this.getChecks(connection, table.name, table.schema, ctx);
102
102
  const pks = await this.getPrimaryKeys(connection, indexes, table.name, table.schema, ctx);
103
103
  const fks = await this.getForeignKeys(connection, table.name, table.schema, ctx);
104
- const enums = await this.getEnumDefinitions(connection, table.name, table.schema, ctx);
104
+ const enums = this.extractEnumValuesFromChecks(checks);
105
105
  const triggers = await this.getTableTriggers(connection, table.name);
106
106
  table.init(cols, indexes, checks, pks, fks, enums);
107
107
  table.setTriggers(triggers);
@@ -165,6 +165,7 @@ export class SqliteSchemaHelper extends SchemaHelper {
165
165
  col.push(`check (${checks[check].expression})`);
166
166
  checks.splice(check, 1);
167
167
  }
168
+ Utils.runIfNotEmpty(() => col.push(this.getCollateSQL(column.collation)), column.collation);
168
169
  Utils.runIfNotEmpty(() => col.push('null'), column.nullable);
169
170
  Utils.runIfNotEmpty(() => col.push('not null'), !column.nullable && !column.generated);
170
171
  Utils.runIfNotEmpty(() => col.push('primary key'), column.primary);
@@ -291,6 +292,17 @@ export class SqliteSchemaHelper extends SchemaHelper {
291
292
  generated = `${match[2]} ${storage}`;
292
293
  }
293
294
  }
295
+ // Strip string literals first (their contents could contain unbalanced parens), then
296
+ // repeatedly strip the innermost balanced `(...)` until none remain — a single pass would
297
+ // only remove the innermost level, leaving `collate` tokens inside nested CHECK/default
298
+ // expressions exposed to the column-collation regex.
299
+ let cleanDef = (columnDefinitions[col.name]?.definition ?? '').replace(/'[^']*'/g, '').replace(/"[^"]*"/g, '');
300
+ let prev;
301
+ do {
302
+ prev = cleanDef;
303
+ cleanDef = cleanDef.replace(/\([^()]*\)/g, '');
304
+ } while (cleanDef !== prev);
305
+ const collationMatch = /\bcollate\s+([`"']?)([\w\-.]+)\1/i.exec(cleanDef);
294
306
  return {
295
307
  name: col.name,
296
308
  type: col.type,
@@ -301,6 +313,7 @@ export class SqliteSchemaHelper extends SchemaHelper {
301
313
  unsigned: false,
302
314
  autoincrement: !composite && col.pk && this.platform.isNumericColumn(mappedType) && hasAutoincrement,
303
315
  generated,
316
+ collation: collationMatch ? collationMatch[2] : undefined,
304
317
  };
305
318
  });
306
319
  }
@@ -327,23 +340,23 @@ export class SqliteSchemaHelper extends SchemaHelper {
327
340
  // everything else is an expression that had its outer parens stripped
328
341
  return `(${value})`;
329
342
  }
330
- async getEnumDefinitions(connection, tableName, schemaName, ctx) {
331
- const prefix = this.getSchemaPrefix(schemaName);
332
- const sql = `select sql from ${prefix}sqlite_master where type = ? and name = ?`;
333
- const tableDefinition = await connection.execute(sql, ['table', tableName], 'get', ctx);
334
- const checkConstraints = [...(tableDefinition.sql.match(/[`["'][^`\]"']+[`\]"'] text check \(.*?\)/gi) ?? [])];
335
- return checkConstraints.reduce((o, item) => {
336
- // check constraints are defined as (note that last closing paren is missing):
337
- // `type` text check (`type` in ('local', 'global')
338
- const match = /[`["']([^`\]"']+)[`\]"'] text check \(.* \((.*)\)/i.exec(item);
339
- /* v8 ignore next */
340
- if (match) {
341
- o[match[1]] = match[2]
342
- .split(/,(?=\s*'(?:[^']|'')*'(?:\s*\)|$))/)
343
- .map((item) => /^\(?'((?:[^']|'')*)'/.exec(item.trim())[1].replace(/''/g, "'"));
343
+ /** Extract enum values from `IN (…)` CHECKs only — a `!= 'x'` check would otherwise be misread as a one-item enum. */
344
+ extractEnumValuesFromChecks(checks) {
345
+ const result = {};
346
+ for (const check of checks) {
347
+ if (!check.columnName || typeof check.expression !== 'string') {
348
+ continue;
344
349
  }
345
- return o;
346
- }, {});
350
+ const inClause = /\bin\s*\(([^)]*)\)/i.exec(check.expression);
351
+ if (!inClause) {
352
+ continue;
353
+ }
354
+ const items = [...inClause[1].matchAll(/'((?:[^']|'')*)'/g)].map(m => m[1].replace(/''/g, "'"));
355
+ if (items.length > 0) {
356
+ result[check.columnName] = items;
357
+ }
358
+ }
359
+ return result;
347
360
  }
348
361
  async getPrimaryKeys(connection, indexes, tableName, schemaName, ctx) {
349
362
  const prefix = this.getSchemaPrefix(schemaName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.1.0-dev.20",
3
+ "version": "7.1.0-dev.22",
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.12"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.1.0-dev.20"
56
+ "@mikro-orm/core": "7.1.0-dev.22"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -341,12 +341,16 @@ export class DatabaseSchema {
341
341
  (prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner));
342
342
  }
343
343
  toJSON() {
344
+ // locale-independent comparison so the snapshot is stable across machines
345
+ const byString = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
346
+ const tableKey = (t) => `${t.schema ?? ''}.${t.name}`;
347
+ const byTable = (a, b) => byString(tableKey(a), tableKey(b));
344
348
  return {
345
349
  name: this.name,
346
- namespaces: [...this.#namespaces],
347
- tables: this.#tables,
348
- views: this.#views,
349
- nativeEnums: this.#nativeEnums,
350
+ namespaces: [...this.#namespaces].sort(),
351
+ tables: [...this.#tables].sort(byTable),
352
+ views: [...this.#views].sort(byTable),
353
+ nativeEnums: Object.fromEntries(Object.entries(this.#nativeEnums).sort(([a], [b]) => byString(a, b))),
350
354
  };
351
355
  }
352
356
  prune(schema, wildcardSchemaTables) {
@@ -16,6 +16,13 @@ export declare class DatabaseTable {
16
16
  }>;
17
17
  comment?: string;
18
18
  partitioning?: TablePartitioning;
19
+ /**
20
+ * Effective collation the column defaults to when no explicit `COLLATE` is set on a column.
21
+ * For MySQL/MariaDB this is the table collation; for PostgreSQL and MSSQL this is the database default;
22
+ * SQLite has no configurable default. Used by `SchemaComparator.diffCollation` to avoid flapping
23
+ * when a property explicitly names the default collation.
24
+ */
25
+ collation?: string;
19
26
  constructor(platform: AbstractSqlPlatform, name: string, schema?: string | undefined);
20
27
  getQuotedName(): string;
21
28
  getColumns(): Column[];
@@ -15,6 +15,13 @@ export class DatabaseTable {
15
15
  nativeEnums = {}; // for postgres
16
16
  comment;
17
17
  partitioning;
18
+ /**
19
+ * Effective collation the column defaults to when no explicit `COLLATE` is set on a column.
20
+ * For MySQL/MariaDB this is the table collation; for PostgreSQL and MSSQL this is the database default;
21
+ * SQLite has no configurable default. Used by `SchemaComparator.diffCollation` to avoid flapping
22
+ * when a property explicitly names the default collation.
23
+ */
24
+ collation;
18
25
  constructor(platform, name, schema) {
19
26
  this.name = name;
20
27
  this.schema = schema;
@@ -68,6 +75,7 @@ export class DatabaseTable {
68
75
  this.#indexes = indexes;
69
76
  this.#checks = checks;
70
77
  this.#foreignKeys = fks;
78
+ const helper = this.#platform.getSchemaHelper();
71
79
  this.#columns = cols.reduce((o, v) => {
72
80
  const index = indexes.filter(i => i.columnNames[0] === v.name);
73
81
  v.primary = v.primary || pks.includes(v.name);
@@ -76,6 +84,11 @@ export class DatabaseTable {
76
84
  v.mappedType = this.#platform.getMappedType(type);
77
85
  v.default = v.default?.toString().startsWith('nextval(') ? null : v.default;
78
86
  v.enumItems ??= enums[v.name] || [];
87
+ // recover length from the declared type so introspection matches `addColumnFromProperty`;
88
+ // scoped to types with `getDefaultLength` to skip mysql's `tinyint(1)` boolean width
89
+ if (v.length == null && v.type && helper && typeof v.mappedType.getDefaultLength !== 'undefined') {
90
+ v.length = helper.inferLengthFromColumnType(v.type);
91
+ }
79
92
  o[v.name] = v;
80
93
  return o;
81
94
  }, {});
@@ -85,7 +98,9 @@ export class DatabaseTable {
85
98
  }
86
99
  addColumnFromProperty(prop, meta, config) {
87
100
  prop.fieldNames?.forEach((field, idx) => {
88
- const type = prop.enum ? 'enum' : prop.columnTypes[idx];
101
+ // numeric enums fall through to the underlying numeric type no platform emits a CHECK we could parse back
102
+ const isStringEnum = !!prop.nativeEnumName || !!prop.items?.every(item => typeof item === 'string');
103
+ const type = prop.enum && isStringEnum ? 'enum' : prop.columnTypes[idx];
89
104
  const mappedType = this.#platform.getMappedType(type);
90
105
  if (mappedType instanceof DecimalType) {
91
106
  const match = /\w+\((\d+), ?(\d+)\)/.exec(prop.columnTypes[idx]);
@@ -122,6 +137,7 @@ export class DatabaseTable {
122
137
  default: prop.defaultRaw,
123
138
  enumItems: prop.nativeEnumName || prop.items?.every(i => typeof i === 'string') ? prop.items : undefined,
124
139
  comment: prop.comment,
140
+ collation: prop.collation,
125
141
  extra: prop.extra,
126
142
  ignoreSchemaChanges: prop.ignoreSchemaChanges,
127
143
  };
@@ -657,6 +673,7 @@ export class DatabaseTable {
657
673
  columnOptions.scale = column.scale;
658
674
  columnOptions.extra = column.extra;
659
675
  columnOptions.comment = column.comment;
676
+ columnOptions.collation = column.collation;
660
677
  columnOptions.enum = !!column.enumItems?.length;
661
678
  columnOptions.items = column.enumItems;
662
679
  }
@@ -723,6 +740,7 @@ export class DatabaseTable {
723
740
  scale: column.scale,
724
741
  extra: column.extra,
725
742
  comment: column.comment,
743
+ collation: column.collation,
726
744
  index: index ? index.keyName : undefined,
727
745
  unique: unique ? unique.keyName : undefined,
728
746
  enum: !!column.enumItems?.length,
@@ -933,52 +951,138 @@ export class DatabaseTable {
933
951
  }
934
952
  toJSON() {
935
953
  const columns = this.#columns;
936
- const columnsMapped = Utils.keys(columns).reduce((o, col) => {
954
+ // locale-independent comparison so the snapshot is stable across machines
955
+ const byString = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
956
+ const sortedColumnKeys = Utils.keys(columns).sort(byString);
957
+ // mirror `DatabaseTable.init()`: derive `primary`/`unique` from index membership
958
+ // so metadata (which keeps `primary: false` on composite PK columns) and introspection agree
959
+ const primaryColumns = new Set();
960
+ const uniqueColumns = new Set();
961
+ for (const idx of this.#indexes) {
962
+ if (idx.primary) {
963
+ idx.columnNames.forEach(c => primaryColumns.add(c));
964
+ }
965
+ if (idx.unique && !idx.primary && idx.columnNames.length > 0) {
966
+ uniqueColumns.add(idx.columnNames[0]);
967
+ }
968
+ }
969
+ // integer/float widths live in the type name (`int2`/`int4`/`float8`) — drop redundant precision/scale
970
+ const isFixedPrecisionFamily = (mappedType) => mappedType instanceof t.integer ||
971
+ mappedType instanceof t.smallint ||
972
+ mappedType instanceof t.tinyint ||
973
+ mappedType instanceof t.mediumint ||
974
+ mappedType instanceof t.bigint ||
975
+ mappedType instanceof t.float ||
976
+ mappedType instanceof t.double;
977
+ const supportsUnsigned = this.#platform.supportsUnsigned();
978
+ const columnsMapped = sortedColumnKeys.reduce((o, col) => {
937
979
  const c = columns[col];
980
+ // omit `autoincrement` from options so `serial` (metadata) and `int4` (introspection) collapse the same
981
+ const rawType = c.type?.toLowerCase();
982
+ const normOptions = { length: c.length, precision: c.precision, scale: c.scale };
983
+ const type = this.#platform.normalizeColumnType(c.type ?? '', normOptions)?.toLowerCase() || rawType;
984
+ const fixedPrecision = isFixedPrecisionFamily(c.mappedType);
938
985
  const normalized = {
939
986
  name: c.name,
940
- type: c.type,
941
- unsigned: !!c.unsigned,
987
+ type,
988
+ unsigned: supportsUnsigned && !!c.unsigned,
942
989
  autoincrement: !!c.autoincrement,
943
- primary: !!c.primary,
990
+ primary: primaryColumns.has(c.name) || !!c.primary,
944
991
  nullable: !!c.nullable,
945
- unique: !!c.unique,
946
- length: c.length ?? null,
947
- precision: c.precision ?? null,
948
- scale: c.scale ?? null,
992
+ unique: uniqueColumns.has(c.name) || !!c.unique,
993
+ length: c.length || null,
994
+ precision: fixedPrecision ? null : (c.precision ?? null),
995
+ scale: fixedPrecision ? null : (c.scale ?? null),
949
996
  default: c.default ?? null,
950
997
  comment: c.comment ?? null,
998
+ collation: c.collation ?? null,
951
999
  enumItems: c.enumItems ?? [],
952
1000
  mappedType: Utils.keys(t).find(k => t[k] === c.mappedType.constructor),
953
1001
  };
954
- if (c.generated) {
955
- normalized.generated = c.generated;
956
- }
957
- if (c.nativeEnumName) {
958
- normalized.nativeEnumName = c.nativeEnumName;
959
- }
960
- if (c.extra) {
961
- normalized.extra = c.extra;
962
- }
963
- if (c.ignoreSchemaChanges) {
964
- normalized.ignoreSchemaChanges = c.ignoreSchemaChanges;
965
- }
966
- if (c.defaultConstraint) {
967
- normalized.defaultConstraint = c.defaultConstraint;
1002
+ for (const field of [
1003
+ 'generated',
1004
+ 'nativeEnumName',
1005
+ 'extra',
1006
+ 'ignoreSchemaChanges',
1007
+ 'defaultConstraint',
1008
+ ]) {
1009
+ if (c[field]) {
1010
+ normalized[field] = c[field];
1011
+ }
968
1012
  }
969
1013
  o[col] = normalized;
970
1014
  return o;
971
1015
  }, {});
1016
+ const normalizeIndex = (idx) => {
1017
+ const out = {
1018
+ columnNames: idx.columnNames,
1019
+ composite: !!idx.composite,
1020
+ // PK indexes are always backed by a constraint — force it so postgres introspection matches
1021
+ constraint: !!idx.constraint || !!idx.primary,
1022
+ keyName: idx.keyName,
1023
+ primary: !!idx.primary,
1024
+ unique: !!idx.unique,
1025
+ };
1026
+ const optional = [
1027
+ 'expression',
1028
+ 'type',
1029
+ 'deferMode',
1030
+ 'columns',
1031
+ 'include',
1032
+ 'fillFactor',
1033
+ 'invisible',
1034
+ 'disabled',
1035
+ 'clustered',
1036
+ ];
1037
+ for (const field of optional) {
1038
+ if (idx[field] != null && idx[field] !== false) {
1039
+ out[field] = idx[field];
1040
+ }
1041
+ }
1042
+ return out;
1043
+ };
1044
+ const normalizeFk = (fk) => {
1045
+ const isNoAction = (rule) => !rule || rule.toLowerCase() === 'no action';
1046
+ // JSON.stringify drops undefined properties — let them through instead of guarding
1047
+ return {
1048
+ columnNames: fk.columnNames,
1049
+ constraintName: fk.constraintName,
1050
+ localTableName: fk.localTableName,
1051
+ referencedColumnNames: fk.referencedColumnNames,
1052
+ referencedTableName: fk.referencedTableName,
1053
+ updateRule: isNoAction(fk.updateRule) ? undefined : fk.updateRule,
1054
+ deleteRule: isNoAction(fk.deleteRule) ? undefined : fk.deleteRule,
1055
+ deferMode: fk.deferMode,
1056
+ };
1057
+ };
1058
+ const normalizeCheck = (check) => {
1059
+ const out = { name: check.name };
1060
+ if (typeof check.expression === 'string') {
1061
+ out.expression = check.expression;
1062
+ }
1063
+ for (const field of ['definition', 'columnName']) {
1064
+ if (check[field]) {
1065
+ out[field] = check[field];
1066
+ }
1067
+ }
1068
+ return out;
1069
+ };
1070
+ const sortedIndexes = [...this.#indexes].sort((a, b) => byString(a.keyName, b.keyName)).map(normalizeIndex);
1071
+ const sortedChecks = [...this.#checks].sort((a, b) => byString(a.name, b.name)).map(normalizeCheck);
1072
+ const sortedTriggers = [...this.#triggers].sort((a, b) => byString(a.name, b.name));
1073
+ const sortedForeignKeys = Object.fromEntries(Object.entries(this.#foreignKeys)
1074
+ .sort(([a], [b]) => byString(a, b))
1075
+ .map(([k, v]) => [k, normalizeFk(v)]));
972
1076
  return {
973
1077
  name: this.name,
974
1078
  schema: this.schema,
975
1079
  columns: columnsMapped,
976
- indexes: this.#indexes,
977
- checks: this.#checks,
978
- triggers: this.#triggers,
979
- foreignKeys: this.#foreignKeys,
980
- nativeEnums: this.nativeEnums,
981
- comment: this.comment,
1080
+ indexes: sortedIndexes,
1081
+ checks: sortedChecks,
1082
+ triggers: sortedTriggers,
1083
+ foreignKeys: sortedForeignKeys,
1084
+ // emit `comment` even when unset so introspection (which always reads it) matches metadata
1085
+ comment: this.comment ?? null,
982
1086
  };
983
1087
  }
984
1088
  }
@@ -39,6 +39,15 @@ export declare class SchemaComparator {
39
39
  diffColumn(fromColumn: Column, toColumn: Column, fromTable: DatabaseTable, logging?: boolean): Set<string>;
40
40
  diffEnumItems(items1?: string[], items2?: string[]): boolean;
41
41
  diffComment(comment1?: string, comment2?: string): boolean;
42
+ /**
43
+ * `from` is the introspected DB state, `to` is the target metadata. A column-level `COLLATE`
44
+ * clause naming the table/database default is just verbose syntax for inheriting that default,
45
+ * so both sides are normalized — anything matching `tableDefault` collapses to `undefined` and
46
+ * compares equal to "no explicit collation". Comparison is case-insensitive on dialects that
47
+ * treat collation identifiers as case-insensitive (MySQL/MSSQL/SQLite); PostgreSQL's
48
+ * `pg_collation.collname` is case-sensitive and is compared verbatim.
49
+ */
50
+ diffCollation(fromCollation?: string, toCollation?: string, tableDefault?: string): boolean;
42
51
  /**
43
52
  * Finds the difference between the indexes index1 and index2.
44
53
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
@@ -571,6 +571,11 @@ export class SchemaComparator {
571
571
  log(`'comment' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
572
572
  changedProperties.add('comment');
573
573
  }
574
+ if (!(fromColumn.ignoreSchemaChanges?.includes('collation') || toColumn.ignoreSchemaChanges?.includes('collation')) &&
575
+ this.diffCollation(fromColumn.collation, toColumn.collation, fromTable.collation)) {
576
+ log(`'collation' changed for column ${fromTable.name}.${fromColumn.name}`, { fromColumn, toColumn });
577
+ changedProperties.add('collation');
578
+ }
574
579
  const isNonNativeEnumArray = !(fromColumn.nativeEnumName || toColumn.nativeEnumName) &&
575
580
  (fromColumn.mappedType instanceof ArrayType || toColumn.mappedType instanceof ArrayType);
576
581
  if (!isNonNativeEnumArray && this.diffEnumItems(fromColumn.enumItems, toColumn.enumItems)) {
@@ -592,6 +597,19 @@ export class SchemaComparator {
592
597
  // eslint-disable-next-line eqeqeq
593
598
  return comment1 != comment2 && !(comment1 == null && comment2 === '') && !(comment2 == null && comment1 === '');
594
599
  }
600
+ /**
601
+ * `from` is the introspected DB state, `to` is the target metadata. A column-level `COLLATE`
602
+ * clause naming the table/database default is just verbose syntax for inheriting that default,
603
+ * so both sides are normalized — anything matching `tableDefault` collapses to `undefined` and
604
+ * compares equal to "no explicit collation". Comparison is case-insensitive on dialects that
605
+ * treat collation identifiers as case-insensitive (MySQL/MSSQL/SQLite); PostgreSQL's
606
+ * `pg_collation.collname` is case-sensitive and is compared verbatim.
607
+ */
608
+ diffCollation(fromCollation, toCollation, tableDefault) {
609
+ const fold = this.#platform.caseInsensitiveCollationNames() ? (s) => s.toLowerCase() : (s) => s;
610
+ const norm = (c) => c && tableDefault && fold(c) === fold(tableDefault) ? undefined : c == null ? undefined : fold(c);
611
+ return norm(fromCollation) !== norm(toCollation);
612
+ }
595
613
  /**
596
614
  * Finds the difference between the indexes index1 and index2.
597
615
  * Compares index1 with index2 and returns index2 if there are any differences or false in case there are no differences.
@@ -97,6 +97,8 @@ export declare abstract class SchemaHelper {
97
97
  hasNonDefaultPrimaryKeyName(table: DatabaseTable): boolean;
98
98
  castColumn(name: string, type: string): string;
99
99
  alterTableColumn(column: Column, table: DatabaseTable, changedProperties: Set<string>): string[];
100
+ /** Returns the bare `collate <name>` clause for column DDL. Overridden by PostgreSQL to quote the identifier. */
101
+ protected getCollateSQL(collation: string): string;
100
102
  createTableColumn(column: Column, table: DatabaseTable, changedProperties?: Set<string>): string | undefined;
101
103
  getPreAlterTable(tableDiff: TableDifference, safe: boolean): string[];
102
104
  getPostAlterTable(tableDiff: TableDifference, safe: boolean): string[];
@@ -42,7 +42,7 @@ export class SchemaHelper {
42
42
  }
43
43
  inferLengthFromColumnType(type) {
44
44
  const match = /^\w+\s*(?:\(\s*(\d+)\s*\)|$)/.exec(type);
45
- if (!match) {
45
+ if (match?.[1] == null) {
46
46
  return;
47
47
  }
48
48
  return +match[1];
@@ -381,7 +381,7 @@ export class SchemaHelper {
381
381
  this.append(ret, this.alterTableColumn(column, diff.fromTable, changedProperties));
382
382
  }
383
383
  for (const { column, changedProperties } of Object.values(diff.changedColumns).filter(diff => diff.changedProperties.has('comment'))) {
384
- if (['type', 'nullable', 'autoincrement', 'unsigned', 'default', 'enumItems'].some(t => changedProperties.has(t))) {
384
+ if (['type', 'nullable', 'autoincrement', 'unsigned', 'default', 'enumItems', 'collation'].some(t => changedProperties.has(t))) {
385
385
  continue; // will be handled via column update
386
386
  }
387
387
  ret.push(this.getChangeColumnCommentSQL(tableName, column, schemaName));
@@ -458,7 +458,7 @@ export class SchemaHelper {
458
458
  if (changedProperties.has('default') && column.default == null) {
459
459
  sql.push(`alter table ${table.getQuotedName()} alter column ${this.quote(column.name)} drop default`);
460
460
  }
461
- if (changedProperties.has('type')) {
461
+ if (changedProperties.has('type') || changedProperties.has('collation')) {
462
462
  let type = column.type + (column.generated ? ` generated always as ${column.generated}` : '');
463
463
  if (column.nativeEnumName) {
464
464
  const parts = type.split('.');
@@ -470,7 +470,8 @@ export class SchemaHelper {
470
470
  }
471
471
  type = this.quote(type);
472
472
  }
473
- sql.push(`alter table ${table.getQuotedName()} alter column ${this.quote(column.name)} type ${type + this.castColumn(column.name, type)}`);
473
+ const collateClause = column.collation ? ` ${this.getCollateSQL(column.collation)}` : '';
474
+ sql.push(`alter table ${table.getQuotedName()} alter column ${this.quote(column.name)} type ${type + collateClause + this.castColumn(column.name, type)}`);
474
475
  }
475
476
  if (changedProperties.has('default') && column.default != null) {
476
477
  sql.push(`alter table ${table.getQuotedName()} alter column ${this.quote(column.name)} set default ${column.default}`);
@@ -481,6 +482,11 @@ export class SchemaHelper {
481
482
  }
482
483
  return sql;
483
484
  }
485
+ /** Returns the bare `collate <name>` clause for column DDL. Overridden by PostgreSQL to quote the identifier. */
486
+ getCollateSQL(collation) {
487
+ this.platform.validateCollationName(collation);
488
+ return `collate ${collation}`;
489
+ }
484
490
  createTableColumn(column, table, changedProperties) {
485
491
  const compositePK = table.getPrimaryKey()?.composite;
486
492
  const primaryKey = !changedProperties && !this.hasNonDefaultPrimaryKeyName(table);
@@ -488,6 +494,7 @@ export class SchemaHelper {
488
494
  const useDefault = column.default != null && column.default !== 'null' && !column.autoincrement;
489
495
  const col = [this.quote(column.name), columnType];
490
496
  Utils.runIfNotEmpty(() => col.push('unsigned'), column.unsigned && this.platform.supportsUnsigned());
497
+ Utils.runIfNotEmpty(() => col.push(this.getCollateSQL(column.collation)), column.collation);
491
498
  Utils.runIfNotEmpty(() => col.push('null'), column.nullable);
492
499
  Utils.runIfNotEmpty(() => col.push('not null'), !column.nullable && !column.generated);
493
500
  Utils.runIfNotEmpty(() => col.push('auto_increment'), column.autoincrement);
package/typings.d.ts CHANGED
@@ -44,6 +44,7 @@ export interface Column {
44
44
  default?: string | null;
45
45
  defaultConstraint?: string;
46
46
  comment?: string;
47
+ collation?: string;
47
48
  generated?: string;
48
49
  nativeEnumName?: string;
49
50
  enumItems?: string[];
@@ -51,7 +52,7 @@ export interface Column {
51
52
  unique?: boolean;
52
53
  /** mysql only */
53
54
  extra?: string;
54
- ignoreSchemaChanges?: ('type' | 'extra' | 'default')[];
55
+ ignoreSchemaChanges?: ('type' | 'extra' | 'default' | 'collation')[];
55
56
  }
56
57
  export interface ForeignKey {
57
58
  columnNames: string[];