@mikro-orm/sql 7.2.6-dev.8 → 7.2.6

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.
@@ -130,6 +130,10 @@ export class AbstractSqlConnection extends Connection {
130
130
  }
131
131
  catch (rollbackError) {
132
132
  this.logger.warn('query', `Failed to roll back transaction: ${rollbackError.message}`);
133
+ // the inner writes are still part of the outer transaction, so it must not commit them
134
+ if (options.ctx) {
135
+ options.ctx.failedSavepointRollback ??= new Error(`Cannot commit the transaction, rolling back to savepoint "${trx.savepointName}" failed: ${rollbackError.message}`, { cause: rollbackError });
136
+ }
133
137
  }
134
138
  throw error;
135
139
  }
@@ -203,6 +207,9 @@ export class AbstractSqlConnection extends Connection {
203
207
  if (ctx.isRolledBack) {
204
208
  return;
205
209
  }
210
+ if ('failedSavepointRollback' in ctx) {
211
+ throw ctx.failedSavepointRollback;
212
+ }
206
213
  await eventBroadcaster?.dispatchEvent(EventType.beforeTransactionCommit, ctx);
207
214
  if ('savepointName' in ctx) {
208
215
  await ctx.releaseSavepoint(ctx.savepointName).execute();
@@ -467,9 +467,14 @@ export class AbstractSqlDriver extends DatabaseDriver {
467
467
  return;
468
468
  }
469
469
  const mapToPk = !hint.dataOnly && !!(ref || prop.mapToPk);
470
- const targetProps = mapToPk
471
- ? meta2.getPrimaryProps()
472
- : meta2.props.filter(prop => this.platform.shouldHaveColumn(prop, hint.children || []));
470
+ // all rows of a result share their columns, so resolve the mapped props once per join path
471
+ const joinedTargets = (map.joinedTargets ??= {});
472
+ const { targetProps, virtualProps } = (joinedTargets[path] ??= {
473
+ targetProps: (mapToPk
474
+ ? meta2.getPrimaryProps()
475
+ : meta2.props.filter(prop => this.platform.shouldHaveColumn(prop, hint.children || []))).filter(prop => prop.fieldNames.some(name => typeof root[`${relationAlias}__${name}`] !== 'undefined')),
476
+ virtualProps: meta2.props.filter(prop => !ref && prop.persist === false && prop.fieldNames),
477
+ });
473
478
  // If the primary key value for the relation is null, we know we haven't joined to anything
474
479
  // and therefore we don't return any record (since all values would be null)
475
480
  const hasPK = meta2.getPrimaryProps().every(pk => pk.fieldNames.every(name => {
@@ -490,9 +495,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
490
495
  return;
491
496
  }
492
497
  let relationPojo = {};
493
- meta2.props
494
- .filter(prop => !ref && prop.persist === false && prop.fieldNames)
495
- .forEach(prop => {
498
+ virtualProps.forEach(prop => {
496
499
  /* v8 ignore next */
497
500
  if (prop.fieldNames.length > 1) {
498
501
  // composite keys
@@ -2115,7 +2118,7 @@ export class AbstractSqlDriver extends DatabaseDriver {
2115
2118
  if (prop.formula) {
2116
2119
  const quotedAlias = this.platform.quoteIdentifier(tableAlias).toString();
2117
2120
  const table = this.createFormulaTable(quotedAlias, meta, schema);
2118
- const columns = meta.createColumnMappingObject(p => qb.helper.getTPTAliasForProperty(p.name, tableAlias), tableAlias);
2121
+ const columns = meta.createColumnMappingObject(p => qb.helper.getTPTAliasForProperty(p.name, tableAlias), quotedAlias);
2119
2122
  return [raw(`${this.evaluateFormula(prop.formula, columns, table)} as ${aliased}`)];
2120
2123
  }
2121
2124
  if (prop.customTypes?.some(type => !!type?.convertToJSValueSQL)) {
@@ -2539,9 +2542,9 @@ export class AbstractSqlDriver extends DatabaseDriver {
2539
2542
  ret.push('*');
2540
2543
  }
2541
2544
  if (ret.length > 0 && !hasExplicitFields && addFormulas) {
2542
- // Create formula column mapping with unquoted aliases - quoting should be handled by the user via `quote` helper
2545
+ // `toString()` must yield the quoted alias like `QueryBuilder.finalize()`, which dedupes formulas by rendered SQL
2543
2546
  const quotedAlias = this.platform.quoteIdentifier(alias);
2544
- const columns = meta.createColumnMappingObject(alias);
2547
+ const columns = meta.createColumnMappingObject(alias, quotedAlias.toString());
2545
2548
  const effectiveSchema = schema ?? (meta.schema !== '*' ? meta.schema : undefined);
2546
2549
  for (const prop of meta.props) {
2547
2550
  if (lazyProps.includes(prop)) {
@@ -96,7 +96,7 @@ export class AbstractSqlPlatform extends Platform {
96
96
  return this.formatQuery(value.sql, value.params);
97
97
  }
98
98
  if (Utils.isPlainObject(value) || value?.[JsonProperty]) {
99
- return this.escape(JSON.stringify(value));
99
+ return this.escape(this.convertJsonToDatabaseValue(value));
100
100
  }
101
101
  return this.escape(value);
102
102
  }
@@ -16,6 +16,8 @@ export declare class BaseMySqlPlatform extends AbstractSqlPlatform {
16
16
  };
17
17
  /** mysql and mariadb treat null as the lowest value when no placement is requested. */
18
18
  sortsNullsLowest(): boolean;
19
+ /** mysql reads backslashes in string literals as escapes, so defaults need the same escaping as values (minus the needless `\"`). */
20
+ quoteDefaultValue(value: string): string;
19
21
  supportsMultiColumnCountDistinct(): boolean;
20
22
  /** @internal */
21
23
  createNativeQueryBuilder(): MySqlNativeQueryBuilder;
@@ -22,6 +22,10 @@ export class BaseMySqlPlatform extends AbstractSqlPlatform {
22
22
  sortsNullsLowest() {
23
23
  return true;
24
24
  }
25
+ /** mysql reads backslashes in string literals as escapes, so defaults need the same escaping as values (minus the needless `\"`). */
26
+ quoteDefaultValue(value) {
27
+ return this.quoteValue(value).replaceAll('\\"', '"');
28
+ }
25
29
  supportsMultiColumnCountDistinct() {
26
30
  return true;
27
31
  }
@@ -62,4 +62,6 @@ export declare class MySqlSchemaHelper extends SchemaHelper {
62
62
  protected getChecksSQL(tables: Table[]): string;
63
63
  normalizeDefaultValue(defaultValue: string, length: number): string | number;
64
64
  protected wrap(val: string | null | undefined, type: Type<unknown>): string | null | undefined;
65
+ /** Decodes the backslash escapes of an introspected string literal. */
66
+ unescapeDefault(val: string): string;
65
67
  }
@@ -1,4 +1,4 @@
1
- import { EnumType, StringType, TextType } from '@mikro-orm/core';
1
+ import { EnumType, JsonType, StringType, TextType, } from '@mikro-orm/core';
2
2
  import { SchemaHelper } from '../../schema/SchemaHelper.js';
3
3
  export class MySqlSchemaHelper extends SchemaHelper {
4
4
  #cache = {};
@@ -601,7 +601,20 @@ export class MySqlSchemaHelper extends SchemaHelper {
601
601
  return super.normalizeDefaultValue(defaultValue, length, MySqlSchemaHelper.DEFAULT_VALUES);
602
602
  }
603
603
  wrap(val, type) {
604
+ if (typeof val !== 'string' || val.length === 0) {
605
+ return val;
606
+ }
607
+ // text/blob literal defaults come back escaped twice with a charset introducer, e.g. `_utf8mb4\'it\\\'s\'` (JSON is parsed by the comparator)
608
+ const expression = type instanceof JsonType ? null : /^_\w+\\'(.*)\\'$/s.exec(val);
609
+ if (expression) {
610
+ return this.platform.quoteDefaultValue(this.unescapeDefault(expression[1].replace(/\\(.)/gs, '$1')));
611
+ }
604
612
  const stringType = type instanceof StringType || type instanceof TextType || type instanceof EnumType;
605
- return typeof val === 'string' && val.length > 0 && stringType ? this.platform.quoteValue(val) : val;
613
+ return stringType ? this.platform.quoteDefaultValue(val) : val;
614
+ }
615
+ /** Decodes the backslash escapes of an introspected string literal. */
616
+ unescapeDefault(val) {
617
+ const sqlEscapes = { 0: '\0', b: '\b', n: '\n', r: '\r', t: '\t', Z: '\x1a' };
618
+ return val.replace(/\\(.)/gs, (_, char) => sqlEscapes[char] ?? char);
606
619
  }
607
620
  }
@@ -330,10 +330,9 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
330
330
  return columnDefs.map(colDef => {
331
331
  const name = unquote(colDef);
332
332
  const result = { name };
333
- // Find this column in the expression and extract modifiers
334
- // Create a pattern that matches the column name (quoted or unquoted) followed by modifiers
333
+ // Match the column (quoted or unquoted) as a whole list item, so `id` never matches inside `author_id`
335
334
  const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
- const colPattern = new RegExp(`"?${escapedName}"?\\s*([^,)]*?)(?:,|$)`, 'i');
335
+ const colPattern = new RegExp(`(?:^|,)\\s*"?${escapedName}"?(?=\\s|,|$)\\s*([^,)]*?)(?:,|$)`, 'i');
337
336
  const colMatch = columnsStr.match(colPattern);
338
337
  if (colMatch) {
339
338
  const modifiers = colMatch[1];
@@ -1227,7 +1226,7 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
1227
1226
  if (!defaultValue || typeof defaultValue !== 'string') {
1228
1227
  return super.normalizeDefaultValue(defaultValue, length, PostgreSqlSchemaHelper.DEFAULT_VALUES);
1229
1228
  }
1230
- const match = /^'(.*)'::(.*)$/.exec(defaultValue);
1229
+ const match = /^'(.*)'::(.*)$/s.exec(defaultValue);
1231
1230
  if (match) {
1232
1231
  if (match[2] === 'integer') {
1233
1232
  return +match[1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/sql",
3
- "version": "7.2.6-dev.8",
3
+ "version": "7.2.6",
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",
@@ -47,13 +47,13 @@
47
47
  "copy": "node ../../scripts/copy.mjs"
48
48
  },
49
49
  "dependencies": {
50
- "kysely": "0.29.6"
50
+ "kysely": "0.29.7"
51
51
  },
52
52
  "devDependencies": {
53
- "@mikro-orm/core": "^7.2.5"
53
+ "@mikro-orm/core": "^7.2.6"
54
54
  },
55
55
  "peerDependencies": {
56
- "@mikro-orm/core": "7.2.6-dev.8"
56
+ "@mikro-orm/core": "7.2.6"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">= 22.17.0"
@@ -121,6 +121,8 @@ export declare class NativeQueryBuilder implements Subquery {
121
121
  lockMode(lockMode: LockMode, lockTables?: string[]): this;
122
122
  comment(comment: string | string[]): this;
123
123
  hintComment(comment: string | string[]): this;
124
+ /** Comments are interpolated verbatim, so a terminator or a `?` placeholder inside one would corrupt the query. */
125
+ private validateComments;
124
126
  setFlags(flags: Set<QueryFlag>): this;
125
127
  clear(clause: keyof Options): this;
126
128
  wrap(prefix: string, suffix: string): this;
@@ -258,14 +258,23 @@ export class NativeQueryBuilder {
258
258
  }
259
259
  comment(comment) {
260
260
  this.options.comment ??= [];
261
- this.options.comment.push(...Utils.asArray(comment));
261
+ this.options.comment.push(...this.validateComments(comment));
262
262
  return this;
263
263
  }
264
264
  hintComment(comment) {
265
265
  this.options.hintComment ??= [];
266
- this.options.hintComment.push(...Utils.asArray(comment));
266
+ this.options.hintComment.push(...this.validateComments(comment));
267
267
  return this;
268
268
  }
269
+ /** Comments are interpolated verbatim, so a terminator or a `?` placeholder inside one would corrupt the query. */
270
+ validateComments(comment) {
271
+ const comments = Utils.asArray(comment);
272
+ const invalid = comments.find(c => c.includes('/*') || c.includes('*/') || c.includes('?'));
273
+ if (invalid != null) {
274
+ throw new Error(`Query comments cannot contain '/*', '*/' or '?', got: '${invalid}'`);
275
+ }
276
+ return comments;
277
+ }
269
278
  setFlags(flags) {
270
279
  this.options.flags = flags;
271
280
  return this;
@@ -57,6 +57,8 @@ export class ObjectCriteriaNode extends CriteriaNode {
57
57
  // schema-less entities the schema is resolved dynamically and needs to be carried over
58
58
  const fixedSchema = parentMeta.schema && parentMeta.schema !== '*';
59
59
  const qb2 = qb.clone(true, fixedSchema ? undefined : ['schema']);
60
+ // the payload was already converted when processing the parent query
61
+ qb2.unsetFlag(QueryFlag.CONVERT_CUSTOM_TYPES);
60
62
  const joinAlias = qb2.getNextAlias(this.prop.targetMeta.class);
61
63
  const sub = qb2
62
64
  .from(parentMeta.class)
@@ -1157,8 +1157,9 @@ export class QueryBuilder {
1157
1157
  const hash = (data) => {
1158
1158
  return Utils.getPrimaryKeyHash(meta.primaryKeys.map(pk => data[pk]));
1159
1159
  };
1160
+ const map = {};
1160
1161
  for await (const row of res) {
1161
- const mapped = this.driver.mapResult(row, meta, this.#state.populate, this);
1162
+ const mapped = this.driver.mapResult(row, meta, this.#state.populate, this, map);
1162
1163
  if (!options.mergeResults || joinedProps.length === 0) {
1163
1164
  yield this.mapResult(mapped, options.mapResults);
1164
1165
  continue;
@@ -1568,6 +1569,19 @@ export class QueryBuilder {
1568
1569
  if (prop && [ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(prop.kind)) {
1569
1570
  return;
1570
1571
  }
1572
+ // the joined strategy selects by column name, which can collide with the name of another property
1573
+ const shadowed = customAlias &&
1574
+ prop &&
1575
+ (prop.kind === ReferenceKind.EMBEDDED ||
1576
+ (customAlias.endsWith(`__${f}`) && (prop.persist === false || !prop.fieldNames.includes(f)))) &&
1577
+ (this.#state.aliases[a]?.meta ?? this.mainAlias.meta).props.some(p => p !== prop &&
1578
+ p.persist !== false &&
1579
+ (p.kind !== ReferenceKind.EMBEDDED || p.object) &&
1580
+ p.fieldNames?.includes(f));
1581
+ if (shadowed) {
1582
+ ret.push(raw('?? as ??', [`${a}.${f}`, customAlias]));
1583
+ return;
1584
+ }
1571
1585
  if (prop?.persist === false && !prop.embedded && !prop.formula && type === 'where') {
1572
1586
  return;
1573
1587
  }
@@ -1577,11 +1591,7 @@ export class QueryBuilder {
1577
1591
  ret.push(getFieldName(aliased, customAlias));
1578
1592
  return;
1579
1593
  }
1580
- // the joined strategy selects by column name, which can collide with an inline embedded property name
1581
- const shadowed = customAlias &&
1582
- prop?.kind === ReferenceKind.EMBEDDED &&
1583
- (this.#state.aliases[a]?.meta ?? this.mainAlias.meta).props.some(p => (p.kind !== ReferenceKind.EMBEDDED || p.object) && p.fieldNames?.includes(f));
1584
- if (prop?.kind === ReferenceKind.EMBEDDED && !shadowed) {
1594
+ if (prop?.kind === ReferenceKind.EMBEDDED) {
1585
1595
  if (customAlias) {
1586
1596
  throw new Error(`Cannot use 'as ${customAlias}' alias on embedded property '${field}' because it expands to multiple columns. Alias individual fields instead (e.g. '${field}.propertyName as ${customAlias}').`);
1587
1597
  }
@@ -876,9 +876,9 @@ export class DatabaseTable {
876
876
  return +defaultValue;
877
877
  }
878
878
  // unquote string defaults if `raw = false`
879
- const match = /^'(.*)'$/.exec('' + val);
879
+ const match = /^'(.*)'$/s.exec('' + val);
880
880
  if (!raw && match) {
881
- return match[1];
881
+ return schemaHelper.unescapeDefault(match[1]);
882
882
  }
883
883
  return '' + val;
884
884
  }
@@ -924,6 +924,17 @@ export class DatabaseTable {
924
924
  };
925
925
  return [expand(rootProp, 1)];
926
926
  }
927
+ // object embedded property index, the JSON keys are the field names of the embedded properties
928
+ if (rootProp?.embeddable && rootProp.object && rootProp.embeddedProps[parts[1]]) {
929
+ let p = rootProp.embeddedProps[parts[1]];
930
+ let i = 2;
931
+ while (i < parts.length && p.embeddedProps?.[parts[i]]) {
932
+ p = p.embeddedProps[parts[i]];
933
+ i++;
934
+ }
935
+ // a path below the last embedded property targets plain JSON keys, kept as they are
936
+ return [[...p.embeddedPath, ...parts.slice(i)].join('.')];
937
+ }
927
938
  // json index, we need to rename the column only
928
939
  if (rootProp) {
929
940
  return [prop.replace(root, rootProp.fieldNames[0])];
@@ -866,16 +866,10 @@ export class SchemaComparator {
866
866
  if (cols1.length === 0 && cols2.length === 0) {
867
867
  return true;
868
868
  }
869
- // If only one has column options, they don't match
870
- if (cols1.length !== cols2.length) {
871
- return false;
872
- }
873
- // Compare each column's options
874
- // Note: We don't check c1.name !== c2.name because the indexes already have matching columnNames
875
- // and the columns array is derived from those same column names
876
- for (let i = 0; i < cols1.length; i++) {
877
- const c1 = cols1[i];
878
- const c2 = cols2[i];
869
+ // introspection may omit entries for columns with default options, so match them by name
870
+ for (const name of index1.columnNames) {
871
+ const c1 = cols1.find(c => c.name === name) ?? { name };
872
+ const c2 = cols2.find(c => c.name === name) ?? { name };
879
873
  const sort1 = c1.sort?.toUpperCase() ?? 'ASC';
880
874
  const sort2 = c2.sort?.toUpperCase() ?? 'ASC';
881
875
  if (sort1 !== sort2) {
@@ -163,6 +163,8 @@ export declare abstract class SchemaHelper {
163
163
  getNamespaces(connection: AbstractSqlConnection, ctx?: Transaction): Promise<string[]>;
164
164
  protected mapIndexes(indexes: IndexDef[]): Promise<IndexDef[]>;
165
165
  mapForeignKeys(fks: any[], tableName: string, schemaName?: string): Dictionary;
166
+ /** Decodes the content of an introspected string literal, the inverse of `Platform.quoteDefaultValue()`. */
167
+ unescapeDefault(val: string): string;
166
168
  normalizeDefaultValue(defaultValue: string | RawQueryFragment, length?: number, defaultValues?: Dictionary<string[]>): string | number;
167
169
  getCreateDatabaseSQL(name: string): string;
168
170
  getDropDatabaseSQL(name: string): string;
@@ -614,6 +614,8 @@ export class SchemaHelper {
614
614
  }
615
615
  if (useDefault) {
616
616
  // https://dev.mysql.com/doc/refman/9.0/en/data-type-defaults.html
617
+ // `tinytext`, `mediumtext`, `longblob` etc. follow the same rule as `text` and `blob`
618
+ const baseType = column.type.toLowerCase().replace(/^(tiny|medium|long)/, '');
617
619
  const needsExpression = [
618
620
  'blob',
619
621
  'text',
@@ -625,7 +627,7 @@ export class SchemaHelper {
625
627
  'multilinestring',
626
628
  'multipolygon',
627
629
  'geometrycollection',
628
- ].some(type => column.type.toLowerCase().startsWith(type));
630
+ ].some(type => baseType.startsWith(type));
629
631
  const defaultSql = needsExpression && !column.default.startsWith('(') ? `(${column.default})` : column.default;
630
632
  col.push(`default ${defaultSql}`);
631
633
  }
@@ -716,6 +718,10 @@ export class SchemaHelper {
716
718
  return ret;
717
719
  }, {});
718
720
  }
721
+ /** Decodes the content of an introspected string literal, the inverse of `Platform.quoteDefaultValue()`. */
722
+ unescapeDefault(val) {
723
+ return val.replaceAll("''", "'");
724
+ }
719
725
  normalizeDefaultValue(defaultValue, length, defaultValues = {}) {
720
726
  if (defaultValue == null) {
721
727
  return defaultValue;