@mikro-orm/sql 7.0.7-dev.1 → 7.0.7-dev.11

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.
@@ -36,6 +36,8 @@ export declare abstract class AbstractSqlPlatform extends Platform {
36
36
  quoteJsonKey(key: string): string;
37
37
  getJsonIndexDefinition(index: IndexDef): string[];
38
38
  supportsUnionWhere(): boolean;
39
+ /** Whether the platform supports `count(distinct col1, col2)` with multiple columns. If false, a subquery wrapper is used instead. */
40
+ supportsMultiColumnCountDistinct(): boolean;
39
41
  supportsSchemas(): boolean;
40
42
  /** @inheritDoc */
41
43
  generateCustomOrder(escapedColumn: string, values: unknown[]): string;
@@ -91,6 +91,10 @@ export class AbstractSqlPlatform extends Platform {
91
91
  supportsUnionWhere() {
92
92
  return true;
93
93
  }
94
+ /** Whether the platform supports `count(distinct col1, col2)` with multiple columns. If false, a subquery wrapper is used instead. */
95
+ supportsMultiColumnCountDistinct() {
96
+ return false;
97
+ }
94
98
  supportsSchemas() {
95
99
  return false;
96
100
  }
@@ -137,13 +137,18 @@ export class MsSqlNativeQueryBuilder extends NativeQueryBuilder {
137
137
  this.parts[this.parts.length - 1] += ';';
138
138
  }
139
139
  compileSelect() {
140
+ const wrapCountSubquery = this.needsCountSubquery();
141
+ if (wrapCountSubquery) {
142
+ this.parts.push(`select count(*) as ${this.quote('count')} from (`);
143
+ }
140
144
  this.parts.push('select');
141
- if (this.options.limit != null && this.options.offset == null) {
145
+ // skip top(?) inside the count subquery it would limit the distinct rows before counting
146
+ if (this.options.limit != null && this.options.offset == null && !wrapCountSubquery) {
142
147
  this.parts.push(`top (?)`);
143
148
  this.params.push(this.options.limit);
144
149
  }
145
150
  this.addHintComment();
146
- this.parts.push(`${this.getFields()} from ${this.getTableName()}`);
151
+ this.parts.push(`${this.getFields(wrapCountSubquery)} from ${this.getTableName()}`);
147
152
  this.addLockClause();
148
153
  if (this.options.joins) {
149
154
  for (const join of this.options.joins) {
@@ -163,21 +168,27 @@ export class MsSqlNativeQueryBuilder extends NativeQueryBuilder {
163
168
  this.parts.push(`having ${this.options.having.sql}`);
164
169
  this.params.push(...this.options.having.params);
165
170
  }
166
- if (this.options.orderBy) {
167
- this.parts.push(`order by ${this.options.orderBy}`);
168
- }
169
- if (this.options.offset != null) {
170
- /* v8 ignore next */
171
- if (!this.options.orderBy) {
172
- throw new Error('Order by clause is required for pagination');
171
+ if (!wrapCountSubquery) {
172
+ if (this.options.orderBy) {
173
+ this.parts.push(`order by ${this.options.orderBy}`);
173
174
  }
174
- this.parts.push(`offset ? rows`);
175
- this.params.push(this.options.offset);
176
- if (this.options.limit != null) {
177
- this.parts.push(`fetch next ? rows only`);
178
- this.params.push(this.options.limit);
175
+ if (this.options.offset != null) {
176
+ /* v8 ignore next */
177
+ if (!this.options.orderBy) {
178
+ throw new Error('Order by clause is required for pagination');
179
+ }
180
+ this.parts.push(`offset ? rows`);
181
+ this.params.push(this.options.offset);
182
+ if (this.options.limit != null) {
183
+ this.parts.push(`fetch next ? rows only`);
184
+ this.params.push(this.options.limit);
185
+ }
179
186
  }
180
187
  }
188
+ if (wrapCountSubquery) {
189
+ const asKeyword = this.platform.usesAsKeyword() ? ' as ' : ' ';
190
+ this.parts.push(`)${asKeyword}${this.quote('dcnt')}`);
191
+ }
181
192
  }
182
193
  addLockClause() {
183
194
  if (!this.options.lockMode ||
@@ -14,6 +14,7 @@ export declare class BaseMySqlPlatform extends AbstractSqlPlatform {
14
14
  readonly "desc nulls first": "is not null";
15
15
  readonly "desc nulls last": "is null";
16
16
  };
17
+ supportsMultiColumnCountDistinct(): boolean;
17
18
  /** @internal */
18
19
  createNativeQueryBuilder(): MySqlNativeQueryBuilder;
19
20
  getDefaultCharset(): string;
@@ -18,6 +18,9 @@ export class BaseMySqlPlatform extends AbstractSqlPlatform {
18
18
  [QueryOrder.desc_nulls_first]: 'is not null',
19
19
  [QueryOrder.desc_nulls_last]: 'is null',
20
20
  };
21
+ supportsMultiColumnCountDistinct() {
22
+ return true;
23
+ }
21
24
  /** @internal */
22
25
  createNativeQueryBuilder() {
23
26
  return new MySqlNativeQueryBuilder(this);
@@ -213,9 +213,13 @@ export class OracleNativeQueryBuilder extends NativeQueryBuilder {
213
213
  }
214
214
  }
215
215
  compileSelect() {
216
+ const wrapCountSubquery = this.needsCountSubquery();
217
+ if (wrapCountSubquery) {
218
+ this.parts.push(`select count(*) as ${this.quote('count')} from (`);
219
+ }
216
220
  this.parts.push('select');
217
221
  this.addHintComment();
218
- this.parts.push(`${this.getFields()} from ${this.getTableName()}`);
222
+ this.parts.push(`${this.getFields(wrapCountSubquery)} from ${this.getTableName()}`);
219
223
  if (this.options.joins) {
220
224
  for (const join of this.options.joins) {
221
225
  this.parts.push(join.sql);
@@ -234,16 +238,22 @@ export class OracleNativeQueryBuilder extends NativeQueryBuilder {
234
238
  this.parts.push(`having ${this.options.having.sql}`);
235
239
  this.params.push(...this.options.having.params);
236
240
  }
237
- if (this.options.orderBy) {
238
- this.parts.push(`order by ${this.options.orderBy}`);
239
- }
240
- if (this.options.offset != null) {
241
- this.parts.push(`offset ? rows`);
242
- this.params.push(this.options.offset);
241
+ if (!wrapCountSubquery) {
242
+ if (this.options.orderBy) {
243
+ this.parts.push(`order by ${this.options.orderBy}`);
244
+ }
245
+ if (this.options.offset != null) {
246
+ this.parts.push(`offset ? rows`);
247
+ this.params.push(this.options.offset);
248
+ }
249
+ if (this.options.limit != null) {
250
+ this.parts.push(`fetch next ? rows only`);
251
+ this.params.push(this.options.limit);
252
+ }
243
253
  }
244
- if (this.options.limit != null) {
245
- this.parts.push(`fetch next ? rows only`);
246
- this.params.push(this.options.limit);
254
+ if (wrapCountSubquery) {
255
+ const asKeyword = this.platform.usesAsKeyword() ? ' as ' : ' ';
256
+ this.parts.push(`)${asKeyword}${this.quote('dcnt')}`);
247
257
  }
248
258
  }
249
259
  }
@@ -51,17 +51,26 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
51
51
  }
52
52
  }
53
53
  getListMaterializedViewsSQL() {
54
- return (`select matviewname as view_name, schemaname as schema_name, definition as view_definition ` +
54
+ return (`select matviewname as view_name, schemaname as schema_name, definition as view_definition, ispopulated as is_populated ` +
55
55
  `from pg_matviews ` +
56
56
  `where ${this.getIgnoredNamespacesConditionSQL('schemaname')} ` +
57
57
  `order by matviewname`);
58
58
  }
59
59
  async loadMaterializedViews(schema, connection, schemaName) {
60
60
  const views = await connection.execute(this.getListMaterializedViewsSQL());
61
- for (const view of views) {
62
- const definition = view.view_definition?.trim().replace(/;$/, '') ?? '';
61
+ if (views.length === 0) {
62
+ return;
63
+ }
64
+ const tables = views.map(v => ({ table_name: v.view_name, schema_name: v.schema_name }));
65
+ const indexes = await this.getAllIndexes(connection, tables);
66
+ for (let i = 0; i < views.length; i++) {
67
+ const definition = views[i].view_definition?.trim().replace(/;$/, '') ?? '';
63
68
  if (definition) {
64
- schema.addView(view.view_name, view.schema_name, definition, true);
69
+ const dbView = schema.addView(views[i].view_name, views[i].schema_name, definition, true, views[i].is_populated);
70
+ const key = this.getTableKey(tables[i]);
71
+ if (indexes[key]?.length) {
72
+ dbView.indexes = indexes[key];
73
+ }
65
74
  }
66
75
  }
67
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.0.7-dev.1",
3
+ "version": "7.0.7-dev.11",
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.6"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.0.7-dev.1"
56
+ "@mikro-orm/core": "7.0.7-dev.11"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -121,7 +121,9 @@ export declare class NativeQueryBuilder implements Subquery {
121
121
  as(alias: string): this;
122
122
  toRaw(): RawQueryFragment;
123
123
  protected compileSelect(): void;
124
- protected getFields(): string;
124
+ /** Whether this COUNT query needs a subquery wrapper for multi-column distinct. */
125
+ protected needsCountSubquery(): boolean;
126
+ protected getFields(countSubquery?: boolean): string;
125
127
  protected compileInsert(): void;
126
128
  protected addOutputClause(type: 'inserted' | 'deleted'): void;
127
129
  protected processInsertData(): string[];
@@ -281,9 +281,13 @@ export class NativeQueryBuilder {
281
281
  return raw(sql, params);
282
282
  }
283
283
  compileSelect() {
284
+ const wrapCountSubquery = this.needsCountSubquery();
285
+ if (wrapCountSubquery) {
286
+ this.parts.push(`select count(*) as ${this.quote('count')} from (`);
287
+ }
284
288
  this.parts.push('select');
285
289
  this.addHintComment();
286
- this.parts.push(`${this.getFields()} from ${this.getTableName()}`);
290
+ this.parts.push(`${this.getFields(wrapCountSubquery)} from ${this.getTableName()}`);
287
291
  if (this.options.joins) {
288
292
  for (const join of this.options.joins) {
289
293
  this.parts.push(join.sql);
@@ -302,23 +306,40 @@ export class NativeQueryBuilder {
302
306
  this.parts.push(`having ${this.options.having.sql}`);
303
307
  this.params.push(...this.options.having.params);
304
308
  }
305
- if (this.options.orderBy) {
306
- this.parts.push(`order by ${this.options.orderBy}`);
307
- }
308
- if (this.options.limit != null) {
309
- this.parts.push(`limit ?`);
310
- this.params.push(this.options.limit);
309
+ if (!wrapCountSubquery) {
310
+ if (this.options.orderBy) {
311
+ this.parts.push(`order by ${this.options.orderBy}`);
312
+ }
313
+ if (this.options.limit != null) {
314
+ this.parts.push(`limit ?`);
315
+ this.params.push(this.options.limit);
316
+ }
317
+ if (this.options.offset != null) {
318
+ this.parts.push(`offset ?`);
319
+ this.params.push(this.options.offset);
320
+ }
311
321
  }
312
- if (this.options.offset != null) {
313
- this.parts.push(`offset ?`);
314
- this.params.push(this.options.offset);
322
+ if (wrapCountSubquery) {
323
+ const asKeyword = this.platform.usesAsKeyword() ? ' as ' : ' ';
324
+ this.parts.push(`)${asKeyword}${this.quote('dcnt')}`);
315
325
  }
316
326
  }
317
- getFields() {
327
+ /** Whether this COUNT query needs a subquery wrapper for multi-column distinct. */
328
+ needsCountSubquery() {
329
+ return (this.type === QueryType.COUNT &&
330
+ !!this.options.distinct &&
331
+ this.options.select.length > 1 &&
332
+ !this.platform.supportsMultiColumnCountDistinct());
333
+ }
334
+ getFields(countSubquery) {
318
335
  if (!this.options.select || this.options.select.length === 0) {
319
336
  throw new Error('No fields selected');
320
337
  }
321
338
  let fields = this.options.select.map(field => this.quote(field)).join(', ');
339
+ // count subquery emits just `distinct col1, col2` — the outer wrapper adds count(*)
340
+ if (countSubquery) {
341
+ return `distinct ${fields}`;
342
+ }
322
343
  if (this.options.distinct) {
323
344
  fields = `distinct ${fields}`;
324
345
  }
@@ -147,7 +147,27 @@ export class DatabaseSchema {
147
147
  if (meta.view) {
148
148
  const viewDefinition = this.getViewDefinition(meta, em, platform);
149
149
  if (viewDefinition) {
150
- schema.addView(meta.collection, this.getSchemaName(meta, config, schemaName), viewDefinition, meta.materialized, meta.withData);
150
+ const view = schema.addView(meta.collection, this.getSchemaName(meta, config, schemaName), viewDefinition, meta.materialized, meta.withData);
151
+ if (meta.materialized) {
152
+ // Use a DatabaseTable to resolve property names → field names for indexes.
153
+ // addIndex only needs meta + table name, not actual columns.
154
+ const indexTable = new DatabaseTable(platform, meta.collection, this.getSchemaName(meta, config, schemaName));
155
+ meta.indexes.forEach(index => indexTable.addIndex(meta, index, 'index'));
156
+ meta.uniques.forEach(index => indexTable.addIndex(meta, index, 'unique'));
157
+ const pkProps = meta.props.filter(prop => prop.primary);
158
+ indexTable.addIndex(meta, { properties: pkProps.map(prop => prop.name) }, 'primary');
159
+ // Materialized views don't have primary keys or constraints in the DB,
160
+ // convert to match what PostgreSQL stores.
161
+ view.indexes = indexTable.getIndexes().map(idx => {
162
+ if (idx.primary) {
163
+ return { ...idx, primary: false, unique: true, constraint: false };
164
+ }
165
+ if (idx.constraint) {
166
+ return { ...idx, constraint: false };
167
+ }
168
+ return idx;
169
+ });
170
+ }
151
171
  }
152
172
  continue;
153
173
  }
@@ -1,7 +1,7 @@
1
1
  import { type Dictionary } from '@mikro-orm/core';
2
2
  import type { Column, ForeignKey, IndexDef, SchemaDifference, TableDifference } from '../typings.js';
3
3
  import type { DatabaseSchema } from './DatabaseSchema.js';
4
- import type { DatabaseTable } from './DatabaseTable.js';
4
+ import { DatabaseTable } from './DatabaseTable.js';
5
5
  import type { AbstractSqlPlatform } from '../AbstractSqlPlatform.js';
6
6
  /**
7
7
  * Compares two Schemas and return an instance of SchemaDifference.
@@ -1,4 +1,5 @@
1
1
  import { ArrayType, BooleanType, DateTimeType, inspect, JsonType, parseJsonSafe, Utils, } from '@mikro-orm/core';
2
+ import { DatabaseTable } from './DatabaseTable.js';
2
3
  /**
3
4
  * Compares two Schemas and return an instance of SchemaDifference.
4
5
  */
@@ -114,15 +115,16 @@ export class SchemaComparator {
114
115
  }
115
116
  }
116
117
  }
117
- // Compare views
118
+ // Compare views — prefer schema-qualified lookup to avoid matching
119
+ // views with the same name in different schemas
118
120
  for (const toView of toSchema.getViews()) {
119
121
  const viewName = toView.schema ? `${toView.schema}.${toView.name}` : toView.name;
120
- if (!fromSchema.hasView(toView.name) && !fromSchema.hasView(viewName)) {
122
+ if (!fromSchema.hasView(viewName) && !fromSchema.hasView(toView.name)) {
121
123
  diff.newViews[viewName] = toView;
122
124
  this.log(`view ${viewName} added`);
123
125
  }
124
126
  else {
125
- const fromView = fromSchema.getView(toView.name) ?? fromSchema.getView(viewName);
127
+ const fromView = fromSchema.getView(viewName) ?? fromSchema.getView(toView.name);
126
128
  if (fromView && this.diffViewExpression(fromView.definition, toView.definition)) {
127
129
  diff.changedViews[viewName] = { from: fromView, to: toView };
128
130
  this.log(`view ${viewName} changed`);
@@ -132,11 +134,34 @@ export class SchemaComparator {
132
134
  // Check for removed views
133
135
  for (const fromView of fromSchema.getViews()) {
134
136
  const viewName = fromView.schema ? `${fromView.schema}.${fromView.name}` : fromView.name;
135
- if (!toSchema.hasView(fromView.name) && !toSchema.hasView(viewName)) {
137
+ if (!toSchema.hasView(viewName) && !toSchema.hasView(fromView.name)) {
136
138
  diff.removedViews[viewName] = fromView;
137
139
  this.log(`view ${viewName} removed`);
138
140
  }
139
141
  }
142
+ // Diff materialized view indexes using the existing table diff infrastructure.
143
+ // Build transient DatabaseTable objects from view indexes so diffTable handles
144
+ // added/removed/changed/renamed index detection and alterTable emits correct DDL.
145
+ for (const toView of toSchema.getViews()) {
146
+ if (!toView.materialized || toView.withData === false) {
147
+ continue;
148
+ }
149
+ const viewName = toView.schema ? `${toView.schema}.${toView.name}` : toView.name;
150
+ // New or definition-changed views have indexes handled during create/recreate
151
+ if (diff.newViews[viewName] || diff.changedViews[viewName]) {
152
+ continue;
153
+ }
154
+ // If we get here, the view exists in fromSchema (otherwise it would be in diff.newViews)
155
+ const fromView = fromSchema.getView(viewName) ?? fromSchema.getView(toView.name);
156
+ const fromTable = new DatabaseTable(this.#platform, fromView.name, fromView.schema);
157
+ fromTable.init([], fromView.indexes ?? [], [], []);
158
+ const toTable = new DatabaseTable(this.#platform, toView.name, toView.schema);
159
+ toTable.init([], toView.indexes ?? [], [], []);
160
+ const tableDiff = this.diffTable(fromTable, toTable);
161
+ if (tableDiff) {
162
+ diff.changedTables[viewName] = tableDiff;
163
+ }
164
+ }
140
165
  return diff;
141
166
  }
142
167
  /**
@@ -84,7 +84,8 @@ export declare abstract class SchemaHelper {
84
84
  getReferencedTableName(referencedTableName: string, schema?: string): string;
85
85
  createIndex(index: IndexDef, table: DatabaseTable, createPrimary?: boolean): string;
86
86
  createCheck(table: DatabaseTable, check: CheckDef): string;
87
- protected getTableName(table: string, schema?: string): string;
87
+ /** @internal */
88
+ getTableName(table: string, schema?: string): string;
88
89
  getTablesGroupedBySchemas(tables: Table[]): Map<string | undefined, Table[]>;
89
90
  get options(): NonNullable<Options['schemaGenerator']>;
90
91
  protected processComment(comment: string): string;
@@ -593,6 +593,7 @@ export class SchemaHelper {
593
593
  createCheck(table, check) {
594
594
  return `alter table ${table.getQuotedName()} add constraint ${this.quote(check.name)} check (${check.expression})`;
595
595
  }
596
+ /** @internal */
596
597
  getTableName(table, schema) {
597
598
  if (schema && schema !== this.platform.getDefaultSchemaName()) {
598
599
  return `${schema}.${table}`;
@@ -62,6 +62,7 @@ export declare class SqlSchemaGenerator extends AbstractSchemaGenerator<Abstract
62
62
  * Uses topological sort based on view definition string matching.
63
63
  */
64
64
  private sortViewsByDependencies;
65
+ private appendViewCreation;
65
66
  private escapeRegExp;
66
67
  }
67
68
  export { SqlSchemaGenerator as SchemaGenerator };
@@ -89,16 +89,9 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
89
89
  this.append(ret, fks, true);
90
90
  }
91
91
  }
92
- // Create views after tables (views may depend on tables)
93
- // Sort views by dependencies (views depending on other views come later)
94
92
  const sortedViews = this.sortViewsByDependencies(toSchema.getViews());
95
93
  for (const view of sortedViews) {
96
- if (view.materialized) {
97
- this.append(ret, this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true));
98
- }
99
- else {
100
- this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
101
- }
94
+ this.appendViewCreation(ret, view);
102
95
  }
103
96
  return this.wrapSchema(ret, options);
104
97
  }
@@ -338,27 +331,14 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
338
331
  this.append(ret, sql);
339
332
  }
340
333
  }
341
- // Create new views after all table changes are done
342
- // Sort views by dependencies (views depending on other views come later)
343
334
  const sortedNewViews = this.sortViewsByDependencies(Object.values(schemaDiff.newViews));
344
335
  for (const view of sortedNewViews) {
345
- if (view.materialized) {
346
- this.append(ret, this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true));
347
- }
348
- else {
349
- this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
350
- }
336
+ this.appendViewCreation(ret, view);
351
337
  }
352
- // Recreate changed views (also sorted by dependencies)
353
338
  const changedViews = Object.values(schemaDiff.changedViews).map(v => v.to);
354
339
  const sortedChangedViews = this.sortViewsByDependencies(changedViews);
355
340
  for (const view of sortedChangedViews) {
356
- if (view.materialized) {
357
- this.append(ret, this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true));
358
- }
359
- else {
360
- this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
361
- }
341
+ this.appendViewCreation(ret, view);
362
342
  }
363
343
  return this.wrapSchema(ret, options);
364
344
  }
@@ -511,6 +491,22 @@ export class SqlSchemaGenerator extends AbstractSchemaGenerator {
511
491
  // Sort and map back to views
512
492
  return calc.sort().map(index => indexToView.get(index));
513
493
  }
494
+ appendViewCreation(ret, view) {
495
+ if (view.materialized) {
496
+ this.append(ret, this.helper.createMaterializedView(view.name, view.schema, view.definition, view.withData ?? true));
497
+ // Skip indexes for WITH NO DATA views — they have no data to index yet.
498
+ // Indexes will be created on the next schema:update after REFRESH populates data.
499
+ if (view.withData !== false) {
500
+ const viewName = this.helper.getTableName(view.name, view.schema);
501
+ for (const index of view.indexes ?? []) {
502
+ this.append(ret, this.helper.getCreateIndexSQL(viewName, index));
503
+ }
504
+ }
505
+ }
506
+ else {
507
+ this.append(ret, this.helper.createView(view.name, view.schema, view.definition), true);
508
+ }
509
+ }
514
510
  escapeRegExp(string) {
515
511
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
516
512
  }
package/typings.d.ts CHANGED
@@ -142,6 +142,8 @@ export interface DatabaseView {
142
142
  materialized?: boolean;
143
143
  /** For materialized views, whether data was populated on creation. */
144
144
  withData?: boolean;
145
+ /** Indexes on the materialized view. Only materialized views support indexes. */
146
+ indexes?: IndexDef[];
145
147
  }
146
148
  export interface SchemaDifference {
147
149
  newNamespaces: Set<string>;