From 35f485702b31d50c8c4633fae84b50a64d00c014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Thu, 6 Apr 2023 23:37:10 +0200 Subject: [PATCH] feat(core): require explicitly marked raw queries via `raw()` helper --- docs/docs/raw-queries.md | 49 +++++ .../better-sqlite/src/BetterSqlitePlatform.ts | 6 +- .../core/src/metadata/MetadataDiscovery.ts | 4 +- packages/core/src/platforms/Platform.ts | 4 +- packages/core/src/utils/QueryHelper.ts | 73 ------- packages/core/src/utils/RawQueryFragment.ts | 165 ++++++++++++++++ packages/core/src/utils/Utils.ts | 2 +- packages/core/src/utils/index.ts | 1 + packages/knex/src/AbstractSqlDriver.ts | 14 +- packages/knex/src/AbstractSqlPlatform.ts | 5 + packages/knex/src/MonkeyPatchable.ts | 3 + packages/knex/src/query/ObjectCriteriaNode.ts | 11 +- packages/knex/src/query/QueryBuilder.ts | 57 ++++-- packages/knex/src/query/QueryBuilderHelper.ts | 116 ++++++----- packages/mariadb/src/MariaDbPlatform.ts | 6 +- packages/mysql/src/MySqlConnection.ts | 6 +- packages/mysql/src/MySqlPlatform.ts | 6 +- packages/postgresql/src/PostgreSqlPlatform.ts | 6 +- packages/sqlite/src/SqlitePlatform.ts | 6 +- tests/EntityManager.mysql.test.ts | 11 +- tests/EntityManager.postgre.test.ts | 29 ++- tests/EntityManager.sqlite2.test.ts | 13 +- tests/QueryBuilder.test.ts | 180 ++++++++++-------- tests/entities-sql/Book2.ts | 3 +- .../custom-types/json-properties.test.ts | 10 +- .../embedded-entities.mongo.test.ts | 4 +- .../embedded-entities.postgres.test.ts | 10 +- .../EntityAssigner.mongo.test.ts | 5 +- .../virtual-entities.sqlite.test.ts | 4 +- 29 files changed, 521 insertions(+), 288 deletions(-) create mode 100644 docs/docs/raw-queries.md create mode 100644 packages/core/src/utils/RawQueryFragment.ts diff --git a/docs/docs/raw-queries.md b/docs/docs/raw-queries.md new file mode 100644 index 000000000000..4c360b06ab73 --- /dev/null +++ b/docs/docs/raw-queries.md @@ -0,0 +1,49 @@ +--- +title: Using raw SQL query fragments +--- + +## `raw()` helper + +When you want to use a raw SQL fragment as part of your query, you can use the `raw()` helper. It creates a raw SQL query fragment instance that can be assigned to a property or part of a filter. This fragment is represented by `RawQueryFragment` class instance that can be serialized to a string, so it can be used both as an object value and key. When serialized, the fragment key gets cached and only such cached key will be recognized by the ORM. This adds a runtime safety to the raw query fragments. + +> **`raw()` helper is required since v6 to use a raw SQL fragment in your query, both through EntityManager and QueryBuilder.** + +```ts +// as a value +await em.find(User, { time: raw('now()') }); + +// as a key +await em.find(User, { [raw('lower(name)')]: name.toLowerCase() }); + +// value can be empty array +await em.find(User, { [raw('(select 1 = 1)')]: [] }); +``` + +The `raw` helper supports several signatures, you can pass in a callback that receives the current property alias: + +```ts +await em.find(User, { [raw(alias => `lower(${alias}.name)`)]: name.toLowerCase() }); +``` + +### Raw fragments in filters + +When using raw query fragment inside a filter, you might have to use a callback signature to create new raw instance for every filter usage - namely when you use the fragment as an object key, which requires its serialization. + +```ts +@Filter({ name: 'long', cond: () => ({ [raw('length(perex)')]: { $gt: 10000 } }) }) +``` + +## `sql` tagged templates + +You can also use the `sql` tagged template function, which works the same, but supports only the simple string signature: + +```ts +// as a value +await em.find(User, { time: sql`now()` }); + +// as a key +await em.find(User, { [sql`lower(name)`]: name.toLowerCase() }); + +// value can be empty array +await em.find(User, { [sql`(select 1 = 1)`]: [] }); +``` diff --git a/packages/better-sqlite/src/BetterSqlitePlatform.ts b/packages/better-sqlite/src/BetterSqlitePlatform.ts index a6be702d0ecf..681c8693e664 100644 --- a/packages/better-sqlite/src/BetterSqlitePlatform.ts +++ b/packages/better-sqlite/src/BetterSqlitePlatform.ts @@ -1,7 +1,7 @@ // @ts-ignore import { escape } from 'sqlstring-sqlite'; import type { EntityProperty } from '@mikro-orm/core'; -import { expr, JsonProperty, Utils } from '@mikro-orm/core'; +import { JsonProperty, raw, Utils } from '@mikro-orm/core'; import { AbstractSqlPlatform } from '@mikro-orm/knex'; import { BetterSqliteSchemaHelper } from './BetterSqliteSchemaHelper'; import { BetterSqliteExceptionConverter } from './BetterSqliteExceptionConverter'; @@ -106,10 +106,10 @@ export class BetterSqlitePlatform extends AbstractSqlPlatform { const [a, ...b] = path; if (aliased) { - return expr(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.join('.')}')`); + return raw(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.join('.')}')`); } - return `json_extract(${this.quoteIdentifier(a)}, '$.${b.join('.')}')`; + return raw(`json_extract(${this.quoteIdentifier(a)}, '$.${b.join('.')}')`); } override getIndexName(tableName: string, columns: string[], type: 'index' | 'unique' | 'foreign' | 'primary' | 'sequence'): string { diff --git a/packages/core/src/metadata/MetadataDiscovery.ts b/packages/core/src/metadata/MetadataDiscovery.ts index 055608c3e112..1224309f6b72 100644 --- a/packages/core/src/metadata/MetadataDiscovery.ts +++ b/packages/core/src/metadata/MetadataDiscovery.ts @@ -14,6 +14,7 @@ import { MetadataError } from '../errors'; import type { Platform } from '../platforms'; import { ArrayType, BigIntType, BlobType, EnumArrayType, JsonType, t, Type } from '../types'; import { colors } from '../logging/colors'; +import { raw } from '../utils/RawQueryFragment'; export class MetadataDiscovery { @@ -867,7 +868,8 @@ export class MetadataDiscovery { path.push(prop.name); meta.properties[name].fieldNames = [path.join('.')]; // store path for ObjectHydrator - meta.properties[name].fieldNameRaw = this.platform.getSearchJsonPropertySQL(path.join('->'), prop.type, true); // for querying in SQL drivers + const fieldName = raw(this.platform.getSearchJsonPropertySQL(path.join('->'), prop.type, true)); + meta.properties[name].fieldNameRaw = fieldName.sql; // for querying in SQL drivers meta.properties[name].persist = false; // only virtual as we store the whole object } diff --git a/packages/core/src/platforms/Platform.ts b/packages/core/src/platforms/Platform.ts index 22bbd05d90e6..105d46292259 100644 --- a/packages/core/src/platforms/Platform.ts +++ b/packages/core/src/platforms/Platform.ts @@ -2,7 +2,7 @@ import { clone } from '../utils/clone'; import { EntityRepository } from '../entity'; import type { NamingStrategy } from '../naming-strategy'; import { UnderscoreNamingStrategy } from '../naming-strategy'; -import type { Constructor, EntityProperty, IEntityGenerator, IMigrator, IPrimaryKey, ISchemaGenerator, PopulateOptions, Primary, EntityMetadata, SimpleColumnMeta } from '../typings'; +import type { Constructor, EntityProperty, IPrimaryKey, ISchemaGenerator, PopulateOptions, Primary, EntityMetadata, SimpleColumnMeta } from '../typings'; import { ExceptionConverter } from './ExceptionConverter'; import type { EntityManager } from '../EntityManager'; import type { Configuration } from '../utils/Configuration'; @@ -363,7 +363,7 @@ export abstract class Platform { } quoteIdentifier(id: string, quote = '`'): string { - return `${quote}${id.replace('.', `${quote}.${quote}`)}${quote}`; + return `${quote}${id.toString().replace('.', `${quote}.${quote}`)}${quote}`; } quoteValue(value: any): string { diff --git a/packages/core/src/utils/QueryHelper.ts b/packages/core/src/utils/QueryHelper.ts index e1c97e4c30e9..5f2ecedf2b7c 100644 --- a/packages/core/src/utils/QueryHelper.ts +++ b/packages/core/src/utils/QueryHelper.ts @@ -284,76 +284,3 @@ interface ProcessWhereOptions { convertCustomTypes?: boolean; root?: boolean; } - -/** - * Helper for escaping string types, e.g. `keyof T -> string`. - * We can also pass array of strings to allow tuple comparison in SQL drivers. - * Another alternative is to use callback signature, which will give us the current alias in its parameter. - */ -export function expr(sql: (keyof T & string) | (keyof T & string)[] | ((alias: string) => string)): string { - if (sql instanceof Function) { - return sql('[::alias::]'); - } - - if (Array.isArray(sql)) { - return Utils.getPrimaryKeyHash(sql); - } - - return sql; -} - -export class RawQueryFragment { - - readonly sql: string; - readonly params?: unknown[]; - - #used = 0; - - constructor(sql: string, params?: unknown[]) { - this.sql = sql; - - if (params) { - this.params = params; - } - } - - valueOf() { - throw new Error(`Trying to modify raw SQL fragment: '${this.sql}'`); - } - - toJSON() { - throw new Error(`Trying to serialize raw SQL fragment: '${this.sql}'`); - } - - /** @internal */ - use() { - if (this.#used > 0) { - throw new Error(`Cannot reassign already used RawQueryFragment: '${this.sql}'`); - } - - this.#used++; - } - -} - -Object.defineProperties(RawQueryFragment.prototype, { - __raw: { value: true, enumerable: false }, - // toString: { value() { throw new Error(`Trying to serialize raw SQL fragment: '${this.sql}'`); }, enumerable: false }, - // toJSON: { value() { throw new Error(`Trying to serialize raw SQL fragment: '${this.sql}'`); }, enumerable: false }, -}); - -/** - * Creates raw SQL query fragment that can be assigned to a property or part of a filter. - */ -export function raw(sql: string, params?: unknown[] | Dictionary): R { - if (typeof params === 'object' && !Array.isArray(params)) { - const pairs = Object.entries(params); - params = []; - for (const [key, value] of pairs) { - sql = sql.replace(':' + key, '?'); - params.push(value); - } - } - - return new RawQueryFragment(sql, params) as R; -} diff --git a/packages/core/src/utils/RawQueryFragment.ts b/packages/core/src/utils/RawQueryFragment.ts new file mode 100644 index 000000000000..c1a1d4907fab --- /dev/null +++ b/packages/core/src/utils/RawQueryFragment.ts @@ -0,0 +1,165 @@ +import { inspect } from 'util'; +import { Utils } from './Utils'; +import type { Dictionary, EntityKey, AnyString } from '../typings'; + +export class RawQueryFragment { + + static #rawQueryCache = new Map(); + static #index = 0; + + #used = false; + readonly #key: string; + + constructor( + readonly sql: string, + readonly params: unknown[] = [], + ) { + this.#key = `[raw]: ${this.sql}${this.params ? ` (#${RawQueryFragment.#index++})` : ''}`; + } + + valueOf(): string { + throw new Error(`Trying to modify raw SQL fragment: '${this.sql}'`); + } + + toJSON() { + throw new Error(`Trying to serialize raw SQL fragment: '${this.sql}'`); + } + + toString() { + RawQueryFragment.#rawQueryCache.set(this.#key, this); + return this.#key; + } + + /** @internal */ + use() { + if (this.#used) { + throw new Error(`Cannot reassign already used RawQueryFragment: '${this.sql}'`); + } + + this.#used = true; + } + + static isKnownFragment(key: string) { + return this.#rawQueryCache.has(key); + } + + static getKnownFragment(key: string | RawQueryFragment) { + if (key instanceof RawQueryFragment) { + return key; + } + + const raw = this.#rawQueryCache.get(key); + + if (raw) { + this.#rawQueryCache.delete(key); + } + + return raw; + } + + [inspect.custom]() { + if (this.params) { + return { sql: this.sql, params: this.params }; + } + + return { sql: this.sql }; + } + +} + +Object.defineProperties(RawQueryFragment.prototype, { + __raw: { value: true, enumerable: false }, +}); + +/** @internal */ +export const ALIAS_REPLACEMENT = '[::alias::]'; + +/** @internal */ +export const ALIAS_REPLACEMENT_RE = '\\[::alias::\\]'; + +/** + * Creates raw SQL query fragment that can be assigned to a property or part of a filter. This fragment is represented + * by `RawQueryFragment` class instance that can be serialized to a string, so it can be used both as an object value + * and key. When serialized, the fragment key gets cached and only such cached key will be recognized by the ORM. + * This adds a runtime safety to the raw query fragments. + * + * > **`raw()` helper is required since v6 to use a raw fragment in your query, both through EntityManager and QueryBuilder.** + * + * ```ts + * // as a value + * await em.find(User, { time: raw('now()') }); + * + * // as a key + * await em.find(User, { [raw('lower(name)')]: name.toLowerCase() }); + * + * // value can be empty array + * await em.find(User, { [raw('(select 1 = 1)')]: [] }); + * ``` + * + * The `raw` helper supports several signatures, you can pass in a callback that receives the current property alias: + * + * ```ts + * await em.find(User, { [raw(alias => `lower(${alias}.name)`)]: name.toLowerCase() }); + * ``` + * + * You can also use the `sql` tagged template function, which works the same, but supports only the simple string signature: + * + * ```ts + * await em.find(User, { [sql`lower(name)`]: name.toLowerCase() }); + * ``` + * + * When using inside filters, you might have to use a callback signature to create new raw instance for every filter usage. + * + * ```ts + * @Filter({ name: 'long', cond: () => ({ [raw('length(perex)')]: { $gt: 10000 } }) }) + * ``` + */ +export function raw(sql: EntityKey | EntityKey[] | AnyString | ((alias: string) => string) | RawQueryFragment, params?: unknown[] | Dictionary): R { + if (sql instanceof RawQueryFragment) { + return sql as R; + } + + if (sql instanceof Function) { + sql = sql(ALIAS_REPLACEMENT); + } + + if (Array.isArray(sql)) { + // for composite FK we return just a simple string + return Utils.getPrimaryKeyHash(sql) as R; + } + + if (typeof params === 'object' && !Array.isArray(params)) { + const pairs = Object.entries(params); + params = []; + + for (const [key, value] of pairs) { + sql = sql.replace(':' + key, '?'); + params.push(value); + } + } + + return new RawQueryFragment(sql, params) as R; +} + +/** + * Alternative to the `raw()` helper allowing to use it as a tagged template function for the simple cases. + * + * ```ts + * // as a value + * await em.find(User, { time: sql`now()` }); + * + * // as a key + * await em.find(User, { [sql`lower(name)`]: name.toLowerCase() }); + * + * // value can be empty array + * await em.find(User, { [sql`(select 1 = 1)`]: [] }); + * ``` + */ +export function sql(sql: readonly string[], ...values: unknown[]) { + return raw(sql.reduce((query, queryPart, i) => { + const valueExists = i < values.length; + const text = query + queryPart; + + return valueExists ? text + '?' : text; + }, ''), values); +} diff --git a/packages/core/src/utils/Utils.ts b/packages/core/src/utils/Utils.ts index f8997b8134a1..8557fed8542a 100644 --- a/packages/core/src/utils/Utils.ts +++ b/packages/core/src/utils/Utils.ts @@ -1163,7 +1163,7 @@ export class Utils { return Object.entries(obj) as [keyof T, T[keyof T]][]; } - static isRawSql(value: unknown): value is { sql: string; params?: unknown[]; use: () => void } { + static isRawSql(value: unknown): value is { sql: string; params: unknown[]; use: () => void } { return typeof value === 'object' && !!value && '__raw' in value; } diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index b30573b26995..8938a57d38b9 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -8,3 +8,4 @@ export * from './QueryHelper'; export * from './NullHighlighter'; export * from './EntityComparator'; export * from './AbstractSchemaGenerator'; +export * from './RawQueryFragment'; diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index f91f09d8b704..8397816e5ab9 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -41,6 +41,7 @@ import { LoadStrategy, QueryFlag, QueryHelper, + raw, ReferenceKind, Utils, } from '@mikro-orm/core'; @@ -825,12 +826,12 @@ export abstract class AbstractSqlDriver p.customType?.convertToJSValueSQL); const hasExplicitFields = !!fields; const ret: Field[] = []; + let addFormulas = false; if (joinedProps.length > 0) { ret.push(...this.getFieldsForJoinedLoad(qb, meta, fields, populate)); @@ -1062,17 +1064,19 @@ export abstract class AbstractSqlDriver !p.formula).length > 0) { const props = meta.props.filter(prop => this.platform.shouldHaveColumn(prop, populate, false)); ret.push(...Utils.flatten(props.filter(p => !lazyProps.includes(p)).map(p => p.fieldNames))); + addFormulas = true; } else if (hasLazyFormulas || requiresSQLConversion) { ret.push('*'); + addFormulas = true; } - if (ret.length > 0 && !hasExplicitFields) { + if (ret.length > 0 && !hasExplicitFields && addFormulas) { meta.props .filter(prop => prop.formula && !lazyProps.includes(prop)) .forEach(prop => { const alias = qb.ref(qb.alias).toString(); const aliased = qb.ref(prop.fieldNames[0]).toString(); - ret.push(`${prop.formula!(alias)} as ${aliased}`); + ret.push(raw(`${prop.formula!(alias)} as ${aliased}`)); }); meta.props diff --git a/packages/knex/src/AbstractSqlPlatform.ts b/packages/knex/src/AbstractSqlPlatform.ts index a94bd0a4fcfa..fff9a25e1688 100644 --- a/packages/knex/src/AbstractSqlPlatform.ts +++ b/packages/knex/src/AbstractSqlPlatform.ts @@ -63,6 +63,11 @@ export abstract class AbstractSqlPlatform extends Platform { let pos = 0; let ret = ''; + if (sql[0] === '?' && sql[1] !== '?') { + ret += this.quoteValue(params[j++]); + pos = 1; + } + while (pos < sql.length) { const idx = sql.indexOf('?', pos + 1); diff --git a/packages/knex/src/MonkeyPatchable.ts b/packages/knex/src/MonkeyPatchable.ts index 1529b47a00dc..9ba337e5ad18 100644 --- a/packages/knex/src/MonkeyPatchable.ts +++ b/packages/knex/src/MonkeyPatchable.ts @@ -7,6 +7,8 @@ import MySqlDialect from 'knex/lib/dialects/mysql'; // @ts-ignore import MySqlColumnCompiler from 'knex/lib/dialects/mysql/schema/mysql-columncompiler'; // @ts-ignore +import MySqlQueryCompiler from 'knex/lib/dialects/mysql/query/mysql-querycompiler'; +// @ts-ignore import PostgresDialectTableCompiler from 'knex/lib/dialects/postgres/schema/pg-tablecompiler'; // @ts-ignore import Sqlite3Dialect from 'knex/lib/dialects/sqlite3'; @@ -24,6 +26,7 @@ export const MonkeyPatchable = { QueryExecutioner, MySqlDialect, MySqlColumnCompiler, + MySqlQueryCompiler, PostgresDialectTableCompiler, Sqlite3Dialect, Sqlite3DialectTableCompiler, diff --git a/packages/knex/src/query/ObjectCriteriaNode.ts b/packages/knex/src/query/ObjectCriteriaNode.ts index 9819a5ff8a8d..2aa20bfec704 100644 --- a/packages/knex/src/query/ObjectCriteriaNode.ts +++ b/packages/knex/src/query/ObjectCriteriaNode.ts @@ -1,5 +1,5 @@ import type { Dictionary, EntityKey } from '@mikro-orm/core'; -import { ReferenceKind, Utils } from '@mikro-orm/core'; +import { ALIAS_REPLACEMENT, raw, RawQueryFragment, ReferenceKind, Utils } from '@mikro-orm/core'; import { CriteriaNode } from './CriteriaNode'; import type { IQueryBuilder } from '../typings'; import { QueryType } from './enums'; @@ -25,7 +25,7 @@ export class ObjectCriteriaNode extends CriteriaNode { const childNode = this.payload[field] as CriteriaNode; const payload = childNode.process(qb, this.prop ? alias : ownerAlias); const operator = Utils.isOperator(field); - const customExpression = ObjectCriteriaNode.isCustomExpression(field); + const isRawField = RawQueryFragment.isKnownFragment(field); // we need to keep the prefixing for formulas otherwise we would lose aliasing context when nesting inside group operators const virtual = childNode.prop?.persist === false && !childNode.prop?.formula; // if key is missing, we are inside group operator and we need to prefix with alias @@ -36,8 +36,11 @@ export class ObjectCriteriaNode extends CriteriaNode { this.inlineChildPayload(o, payload, field as EntityKey, alias, childAlias); } else if (childNode.shouldRename(payload)) { o[childNode.renameFieldToPK(qb)] = payload; - } else if (primaryKey || virtual || operator || customExpression || field.includes('.') || ![QueryType.SELECT, QueryType.COUNT].includes(qb.type ?? QueryType.SELECT)) { - o[field.replace(/\[::alias::]/g, alias!)] = payload; + } else if (isRawField) { + const rawField = RawQueryFragment.getKnownFragment(field)!; + o[raw(rawField.sql.replaceAll(ALIAS_REPLACEMENT, alias!), rawField.params)] = payload; + } else if (primaryKey || virtual || operator || field.includes('.') || ![QueryType.SELECT, QueryType.COUNT].includes(qb.type ?? QueryType.SELECT)) { + o[field.replaceAll(ALIAS_REPLACEMENT, alias!)] = payload; } else { o[`${alias}.${field}`] = payload; } diff --git a/packages/knex/src/query/QueryBuilder.ts b/packages/knex/src/query/QueryBuilder.ts index 785c5d238fbd..4f3d9d0fcc5b 100644 --- a/packages/knex/src/query/QueryBuilder.ts +++ b/packages/knex/src/query/QueryBuilder.ts @@ -23,13 +23,14 @@ import type { RequiredEntityData, } from '@mikro-orm/core'; import { - raw, helper, LoadStrategy, LockMode, PopulateHint, QueryFlag, QueryHelper, + raw, + RawQueryFragment, ReferenceKind, serialize, Utils, @@ -255,10 +256,15 @@ export class QueryBuilder { where(cond: string, params?: any[], operator?: keyof typeof GroupOperator): this; where(cond: QBFilterQuery | string, params?: keyof typeof GroupOperator | any[], operator?: keyof typeof GroupOperator): this { this.ensureNotFinalized(); - - if (Utils.isString(cond)) { - cond = { [`(${cond})`]: Utils.asArray(params) }; - operator = operator || '$and'; + const rawField = RawQueryFragment.getKnownFragment(cond as string); + + if (rawField) { + const sql = this.platform.formatQuery(rawField.sql, rawField.params); + cond = { [raw(`(${sql})`)]: Utils.asArray(params) }; + operator ??= '$and'; + } else if (Utils.isString(cond)) { + cond = { [raw(`(${cond})`, Utils.asArray(params))]: [] }; + operator ??= '$and'; } else { cond = QueryHelper.processWhere({ where: cond as FilterQuery, @@ -339,10 +345,11 @@ export class QueryBuilder { this.ensureNotFinalized(); if (Utils.isString(cond)) { - cond = { [`(${cond})`]: Utils.asArray(params) }; + cond = { [raw(`(${cond})`, params)]: [] }; } this._having = CriteriaNodeFactory.createNode(this.metadata, this.mainAlias.entityName, cond).process(this); + return this; } @@ -523,21 +530,33 @@ export class QueryBuilder { * Returns the query with parameters as wildcards. */ getQuery(): string { - return this.getKnexQuery().toSQL().toNative().sql; + return this.toQuery().sql; + } + + #query?: { sql: string; _sql: Knex.Sql; params: readonly unknown[] }; + + toQuery(): { sql: string; _sql: Knex.Sql; params: readonly unknown[] } { + if (this.#query) { + return this.#query; + } + + const sql = this.getKnexQuery().toSQL(); + const query = sql.toNative(); + return this.#query = { sql: query.sql, params: query.bindings ?? [], _sql: sql }; } /** * Returns the list of all parameters for this query. */ getParams(): readonly Knex.Value[] { - return this.getKnexQuery().toSQL().toNative().bindings; + return this.toQuery().params as Knex.Value[]; } /** * Returns raw interpolated query string with all the parameters inlined. */ getFormattedQuery(): string { - const query = this.getKnexQuery().toSQL(); + const query = this.toQuery()._sql; return this.platform.formatQuery(query.sql, query.bindings); } @@ -834,6 +853,14 @@ export class QueryBuilder { const ret: Field[] = []; fields.forEach(field => { + const rawField = RawQueryFragment.getKnownFragment(field as string); + + if (rawField) { + const sql = this.platform.formatQuery(rawField.sql, rawField.params); + ret.push(this.knex.raw(sql) as Field); + return; + } + if (!Utils.isString(field)) { ret.push(field); return; @@ -1032,8 +1059,14 @@ export class QueryBuilder { const aliased = this.knex.ref(prop.fieldNames[0]).toString(); return `${prop.formula!(alias)} as ${aliased}`; }) - .filter(field => !this._fields!.includes(field)) - .forEach(field => this.addSelect(field)); + .filter(field => !this._fields!.some(f => { + if (f instanceof RawQueryFragment) { + return f.sql === field && f.params.length === 0; + } + + return f === field; + })) + .forEach(field => this._fields!.push(raw(field))); } this.processPopulateWhere(); @@ -1130,7 +1163,7 @@ export class QueryBuilder { addToSelect.push(fieldName); } - orderBy.push({ [`min(${this.ref(fieldName)}${type})`]: direction }); + orderBy.push({ [raw(`min(${this.ref(fieldName)}${type})`)]: direction }); } } diff --git a/packages/knex/src/query/QueryBuilderHelper.ts b/packages/knex/src/query/QueryBuilderHelper.ts index 30eaadea8a70..8868fe64d85c 100644 --- a/packages/knex/src/query/QueryBuilderHelper.ts +++ b/packages/knex/src/query/QueryBuilderHelper.ts @@ -10,11 +10,14 @@ import type { QBFilterQuery, } from '@mikro-orm/core'; import { + ALIAS_REPLACEMENT_RE, GroupOperator, LockMode, OptimisticLockError, QueryOperator, QueryOrderNumeric, + raw, + RawQueryFragment, ReferenceKind, Utils, } from '@mikro-orm/core'; @@ -41,7 +44,7 @@ export class QueryBuilderHelper { mapper(field: string | Knex.Raw, type?: QueryType, value?: any, alias?: string | null): string; mapper(field: string | Knex.Raw, type = QueryType.SELECT, value?: any, alias?: string | null): string | Knex.Raw { if (Utils.isRawSql(field)) { - return this.platform.formatQuery(field.sql, field.params ?? []); + return this.knex.raw(field.sql, field.params); } if (typeof field !== 'string') { @@ -80,10 +83,10 @@ export class QueryBuilderHelper { return this.knex.raw('(' + parts.map(part => this.knex.ref(part)).join(', ') + ')'); } - let ret = field; - const customExpression = QueryBuilderHelper.isCustomExpression(field, !!alias); + const rawField = RawQueryFragment.getKnownFragment(field); const [a, f] = this.splitField(field as EntityKey); const prop = this.getProperty(f, a); + let ret = field; // embeddable nested path instead of a regular property with table alias, reset alias if (prop?.name === a && prop.embeddedProps[f]) { @@ -106,7 +109,7 @@ export class QueryBuilderHelper { if (prop?.customType?.convertToJSValueSQL) { const prefixed = this.prefix(field, isTableNameAliasRequired, true); - const valueSQL = prop.customType.convertToJSValueSQL!(prefixed, this.platform); + const valueSQL = prop.customType.convertToJSValueSQL(prefixed, this.platform); if (alias === null) { return this.knex.raw(valueSQL); @@ -116,7 +119,7 @@ export class QueryBuilderHelper { } // do not wrap custom expressions - if (!customExpression) { + if (!rawField) { ret = this.prefix(field); } @@ -124,9 +127,8 @@ export class QueryBuilderHelper { ret += ' as ' + alias; } - if (customExpression) { - const bindings = Utils.asArray(value).slice(0, ret.match(/\?/g)?.length ?? 0); - return this.knex.raw(ret, bindings); + if (rawField) { + return this.knex.raw(rawField.sql, rawField.params); } if (!isTableNameAliasRequired || this.isPrefixed(ret) || noPrefix) { @@ -303,22 +305,16 @@ export class QueryBuilderHelper { value = null; } - if (QueryBuilderHelper.isCustomExpression(key)) { - // unwind parameters when ? found in field name - const count = key.concat('?').match(/\?/g)!.length - 1; - const values = Utils.asArray(value); - const params1 = values.slice(0, count).map((c: any) => Utils.isObject(c) ? JSON.stringify(c) : c); - const params2 = values.slice(count); - const k = this.mapper(key, QueryType.SELECT, params1); - const { sql, bindings } = (k as unknown as Knex.QueryBuilder).toSQL().toNative(); + const rawField = RawQueryFragment.getKnownFragment(key); - if (params2.length > 0) { - params.push(...bindings); - params.push(...params2 as Knex.Value[]); - return sql + ' = ?'; - } + if (rawField) { + let sql = rawField.sql; + params.push(...rawField.params as Knex.Value[]); + params.push(...Utils.asArray(value) as Knex.Value[]); - params.push(...bindings); + if ((Utils.asArray(value) as Knex.Value[]).length > 0) { + sql += ' = ?'; + } return sql; } @@ -461,33 +457,24 @@ export class QueryBuilderHelper { return this.processObjectSubCondition(cond, key, qb, method, m, type); } - if (QueryBuilderHelper.isCustomExpression(key)) { - return this.processCustomExpression(qb, m, key, cond, type); - } - const op = cond[key] === null ? 'is' : '='; + const raw = RawQueryFragment.getKnownFragment(key); - if (this.subQueries[key]) { - return void qb[m](this.knex.raw(`(${this.subQueries[key]})`), op, cond[key]); - } + if (raw) { + const value = Utils.asArray(cond[key]); - qb[m](this.mapper(key, type, cond[key], null), op, cond[key]); - } + if (value.length > 0) { + return void qb[m](this.knex.raw(raw.sql, raw.params), op, value[0]); + } - private processCustomExpression(clause: any, m: string, key: string, cond: any, type: QueryType): void { - // unwind parameters when ? found in field name - const count = key.concat('?').match(/\?/g)!.length - 1; - const value = Utils.asArray(cond[key]); - const params1 = value.slice(0, count).map((c: any) => Utils.isObject(c) ? JSON.stringify(c) : c); - const params2 = value.slice(count); - const k = this.mapper(key, type, params1); + return void qb[m](this.knex.raw(raw.sql, raw.params)); + } - if (params2.length > 0) { - const val = params2.length === 1 && params2[0] === null ? null : this.knex.raw('?', params2); - return void clause[m](k, val); + if (this.subQueries[key]) { + return void qb[m](this.knex.raw(`(${this.subQueries[key]})`), op, cond[key]); } - clause[m](k); + qb[m](this.mapper(key, type, cond[key], null), op, cond[key]); } private processObjectSubCondition(cond: any, key: string, qb: Knex.QueryBuilder, method: 'where' | 'having', m: 'where' | 'orWhere' | 'having', type: QueryType): void { @@ -572,22 +559,24 @@ export class QueryBuilderHelper { getQueryOrderFromObject(type: QueryType, orderBy: FlatQueryOrderMap, populate: Dictionary): string { const ret: string[] = []; - Object.keys(orderBy).forEach(k => { - const direction = orderBy[k]; + Object.keys(orderBy).forEach(key => { + const direction = orderBy[key]; const order = Utils.isNumber(direction) ? QueryOrderNumeric[direction] : direction; - if (QueryBuilderHelper.isCustomExpression(k)) { - ret.push(`${k} ${order.toLowerCase()}`); + const raw = RawQueryFragment.getKnownFragment(key); + + if (raw) { + ret.push(`${this.platform.formatQuery(raw.sql, raw.params)} ${order.toLowerCase()}`); return; } - Utils.splitPrimaryKeys(k).forEach(f => { + Utils.splitPrimaryKeys(key).forEach(f => { // eslint-disable-next-line prefer-const let [alias, field] = this.splitField(f, true); alias = populate[alias] || alias; const prop = this.getProperty(field, alias); - const noPrefix = (prop && prop.persist === false && !prop.formula) || QueryBuilderHelper.isCustomExpression(f); + const noPrefix = (prop && prop.persist === false && !prop.formula) || RawQueryFragment.isKnownFragment(f); const column = this.mapper(noPrefix ? field : `${alias}.${field}`, type, undefined, null); /* istanbul ignore next */ const rawColumn = Utils.isString(column) ? column.split('.').map(e => this.knex.ref(e)).join('.') : column; @@ -681,25 +670,26 @@ export class QueryBuilderHelper { qb.update(versionProperty.fieldNames[0], this.knex.raw(sql)); } - static isCustomExpression(field: string, hasAlias = false): boolean { - // if we do not have alias, we don't consider spaces as custom expressions - const re = hasAlias ? /[ ?<>=()'"`:]|^\d/ : /[?<>=()'"`:]|^\d/; - - return !!field.match(re); - } - private prefix(field: string, always = false, quote = false): string { let ret: string; if (!this.isPrefixed(field)) { const alias = always ? (quote ? this.alias : this.platform.quoteIdentifier(this.alias)) + '.' : ''; - const fieldName = this.fieldName(field, this.alias, always); + const fieldName = this.fieldName(field, this.alias, always) as string | RawQueryFragment; - if (QueryBuilderHelper.isCustomExpression(fieldName)) { + if (fieldName instanceof RawQueryFragment) { + return fieldName.sql; + } + + if (RawQueryFragment.isKnownFragment(fieldName)) { return fieldName; } - ret = alias + fieldName; + if (this.isPrefixed(fieldName)) { + ret = fieldName; + } else { + ret = alias + fieldName; + } } else { const [a, ...rest] = field.split('.'); const f = rest.join('.'); @@ -746,17 +736,17 @@ export class QueryBuilderHelper { if (prop.fieldNameRaw) { if (!always) { - return prop.fieldNameRaw - .replace(/\[::alias::]\.?/g, '') - .replace(this.platform.quoteIdentifier('') + '.', ''); + return raw(prop.fieldNameRaw + .replace(new RegExp(ALIAS_REPLACEMENT_RE + '\\.?', 'g'), '') + .replace(this.platform.quoteIdentifier('') + '.', '')); } if (alias) { - return prop.fieldNameRaw.replace(/\[::alias::]/g, alias); + return raw(prop.fieldNameRaw.replace(new RegExp(ALIAS_REPLACEMENT_RE, 'g'), alias)); } /* istanbul ignore next */ - return prop.fieldNameRaw; + return raw(prop.fieldNameRaw); } /* istanbul ignore next */ diff --git a/packages/mariadb/src/MariaDbPlatform.ts b/packages/mariadb/src/MariaDbPlatform.ts index 13ceef4a5cea..fe82b38119d1 100644 --- a/packages/mariadb/src/MariaDbPlatform.ts +++ b/packages/mariadb/src/MariaDbPlatform.ts @@ -3,7 +3,7 @@ import { AbstractSqlPlatform } from '@mikro-orm/knex'; import { MariaDbSchemaHelper } from './MariaDbSchemaHelper'; import { MariaDbExceptionConverter } from './MariaDbExceptionConverter'; import type { SimpleColumnMeta, Type } from '@mikro-orm/core'; -import { expr, Utils } from '@mikro-orm/core'; +import { raw, Utils } from '@mikro-orm/core'; export class MariaDbPlatform extends AbstractSqlPlatform { @@ -19,10 +19,10 @@ export class MariaDbPlatform extends AbstractSqlPlatform { const [a, ...b] = path; if (aliased) { - return expr(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.join('.')}')`); + return raw(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.join('.')}')`); } - return `json_extract(${this.quoteIdentifier(a)}, '$.${b.join('.')}')`; + return raw(`json_extract(${this.quoteIdentifier(a)}, '$.${b.join('.')}')`); } override getBooleanTypeDeclarationSQL(): string { diff --git a/packages/mysql/src/MySqlConnection.ts b/packages/mysql/src/MySqlConnection.ts index 92f36705bb49..5051a0328458 100644 --- a/packages/mysql/src/MySqlConnection.ts +++ b/packages/mysql/src/MySqlConnection.ts @@ -10,7 +10,7 @@ export class MySqlConnection extends AbstractSqlConnection { } private patchKnex() { - const { MySqlColumnCompiler } = MonkeyPatchable; + const { MySqlColumnCompiler, MySqlQueryCompiler } = MonkeyPatchable; // we need the old behaviour to be able to add auto_increment to a column that is already PK MySqlColumnCompiler.prototype.increments = function (options = { primaryKey: true }) { @@ -21,6 +21,10 @@ export class MySqlConnection extends AbstractSqlConnection { MySqlColumnCompiler.prototype.bigincrements = function (options = { primaryKey: true }) { return 'bigint unsigned not null auto_increment' + (this.tableCompiler._canBeAddPrimaryKey(options) ? ' primary key' : ''); }; + + // mysql dialect disallows query non scalar params, but we dont use it to execute the query, it always goes through the `platform.formatQuery()` + delete MySqlQueryCompiler.prototype.whereBasic; + delete MySqlQueryCompiler.prototype.whereRaw; } getDefaultClientUrl(): string { diff --git a/packages/mysql/src/MySqlPlatform.ts b/packages/mysql/src/MySqlPlatform.ts index ecf98901a505..b2700b6121f5 100644 --- a/packages/mysql/src/MySqlPlatform.ts +++ b/packages/mysql/src/MySqlPlatform.ts @@ -2,7 +2,7 @@ import { AbstractSqlPlatform } from '@mikro-orm/knex'; import { MySqlSchemaHelper } from './MySqlSchemaHelper'; import { MySqlExceptionConverter } from './MySqlExceptionConverter'; import type { Dictionary, SimpleColumnMeta, Type, TransformContext } from '@mikro-orm/core'; -import { expr, Utils } from '@mikro-orm/core'; +import { raw, Utils } from '@mikro-orm/core'; export class MySqlPlatform extends AbstractSqlPlatform { @@ -25,10 +25,10 @@ export class MySqlPlatform extends AbstractSqlPlatform { const [a, ...b] = path; if (aliased) { - return expr(alias => `${this.quoteIdentifier(`${alias}.${a}`)}->'$.${b.join('.')}'`); + return raw(alias => `${this.quoteIdentifier(`${alias}.${a}`)}->'$.${b.join('.')}'`); } - return `${this.quoteIdentifier(a)}->'$.${b.join('.')}'`; + return raw(`${this.quoteIdentifier(a)}->'$.${b.join('.')}'`); } override getBooleanTypeDeclarationSQL(): string { diff --git a/packages/postgresql/src/PostgreSqlPlatform.ts b/packages/postgresql/src/PostgreSqlPlatform.ts index 766c73bab953..c18ff6ad1653 100644 --- a/packages/postgresql/src/PostgreSqlPlatform.ts +++ b/packages/postgresql/src/PostgreSqlPlatform.ts @@ -1,6 +1,6 @@ import { Client } from 'pg'; import type { EntityProperty, Type, SimpleColumnMeta, Dictionary } from '@mikro-orm/core'; -import { expr, JsonProperty, Utils } from '@mikro-orm/core'; +import { ALIAS_REPLACEMENT, JsonProperty, raw, Utils } from '@mikro-orm/core'; import { AbstractSqlPlatform } from '@mikro-orm/knex'; import { PostgreSqlSchemaHelper } from './PostgreSqlSchemaHelper'; import { PostgreSqlExceptionConverter } from './PostgreSqlExceptionConverter'; @@ -166,12 +166,12 @@ export class PostgreSqlPlatform extends AbstractSqlPlatform { override getSearchJsonPropertyKey(path: string[], type: string, aliased: boolean): string { const first = path.shift(); const last = path.pop(); - const root = aliased ? expr(alias => this.quoteIdentifier(`${alias}.${first}`)) : this.quoteIdentifier(first!); + const root = this.quoteIdentifier(aliased ? `${ALIAS_REPLACEMENT}.${first}` : first!); const types = { number: 'float8', boolean: 'bool', } as Dictionary; - const cast = (key: string) => type in types ? `(${key})::${types[type]}` : key; + const cast = (key: string) => raw(type in types ? `(${key})::${types[type]}` : key); if (path.length === 0) { return cast(`${root}->>'${last}'`); diff --git a/packages/sqlite/src/SqlitePlatform.ts b/packages/sqlite/src/SqlitePlatform.ts index ea5f7b5dd75c..9aa98e1ad6d0 100644 --- a/packages/sqlite/src/SqlitePlatform.ts +++ b/packages/sqlite/src/SqlitePlatform.ts @@ -1,7 +1,7 @@ // @ts-ignore import { escape } from 'sqlstring-sqlite'; import type { EntityProperty } from '@mikro-orm/core'; -import { expr, JsonProperty, Utils } from '@mikro-orm/core'; +import { raw, JsonProperty, Utils } from '@mikro-orm/core'; import { AbstractSqlPlatform } from '@mikro-orm/knex'; import { SqliteSchemaHelper } from './SqliteSchemaHelper'; import { SqliteExceptionConverter } from './SqliteExceptionConverter'; @@ -106,10 +106,10 @@ export class SqlitePlatform extends AbstractSqlPlatform { const [a, ...b] = path; if (aliased) { - return expr(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.join('.')}')`); + return raw(alias => `json_extract(${this.quoteIdentifier(`${alias}.${a}`)}, '$.${b.join('.')}')`); } - return `json_extract(${this.quoteIdentifier(a)}, '$.${b.join('.')}')`; + return raw(`json_extract(${this.quoteIdentifier(a)}, '$.${b.join('.')}')`); } override getIndexName(tableName: string, columns: string[], type: 'index' | 'unique' | 'foreign' | 'primary' | 'sequence'): string { diff --git a/tests/EntityManager.mysql.test.ts b/tests/EntityManager.mysql.test.ts index 7390a0557bad..5b41b7088174 100644 --- a/tests/EntityManager.mysql.test.ts +++ b/tests/EntityManager.mysql.test.ts @@ -18,7 +18,6 @@ import { SyntaxErrorException, NonUniqueFieldNameException, InvalidFieldNameException, - expr, IsolationLevel, NullHighlighter, PopulateHint, @@ -828,26 +827,26 @@ describe('EntityManagerMySql', () => { orm.em.clear(); const qb1 = orm.em.fork().createQueryBuilder(Book2); - const res1 = await qb1.select('*').where({ 'JSON_CONTAINS(`b0`.`meta`, ?)': [{ foo: 'bar' }, false] }).execute('get'); + const res1 = await qb1.select('*').where({ [raw('JSON_CONTAINS(`b0`.`meta`, ?)', [{ foo: 'bar' }])]: false }).execute('get'); expect(res1.createdAt).toBeDefined(); // @ts-expect-error expect(res1.created_at).not.toBeDefined(); expect(res1.meta).toEqual({ category: 'foo', items: 1 }); const qb2 = orm.em.fork().createQueryBuilder(Book2); - const res2 = await qb2.select('*').where({ 'JSON_CONTAINS(meta, ?)': [{ category: 'foo' }, true] }).execute('get', false); + const res2 = await qb2.select('*').where({ [raw('JSON_CONTAINS(meta, ?)', [{ category: 'foo' }])]: true }).execute('get', false); expect(res2.createdAt).not.toBeDefined(); // @ts-expect-error expect(res2.created_at).toBeDefined(); expect(res2.meta).toEqual({ category: 'foo', items: 1 }); const qb3 = orm.em.fork().createQueryBuilder(Book2); - const res3 = await qb3.select('*').where({ 'JSON_CONTAINS(meta, ?)': [{ category: 'foo' }, true] }).getSingleResult(); + const res3 = await qb3.select('*').where({ [raw('JSON_CONTAINS(meta, ?)', [{ category: 'foo' }])]: true }).getSingleResult(); expect(res3).toBeInstanceOf(Book2); expect(res3!.createdAt).toBeDefined(); expect(res3!.meta).toEqual({ category: 'foo', items: 1 }); - const res4 = await orm.em.fork().findOneOrFail(Book2, { [expr('JSON_CONTAINS(meta, ?)')]: [{ items: 1 }, true] }); + const res4 = await orm.em.fork().findOneOrFail(Book2, { [raw('JSON_CONTAINS(meta, ?)', [{ items: 1 }])]: true }); expect(res4).toBeInstanceOf(Book2); expect(res4.createdAt).toBeDefined(); expect(res4.meta).toEqual({ category: 'foo', items: 1 }); @@ -862,7 +861,7 @@ describe('EntityManagerMySql', () => { orm.em.clear(); const mock = mockLogger(orm, ['query']); - const res4 = await orm.em.findOneOrFail(Book2, { [expr(['price', 'createdAt'])]: { $lte: [100, new Date()] } }); + const res4 = await orm.em.findOneOrFail(Book2, { [raw(['price', 'createdAt'])]: { $lte: [100, new Date()] } }); expect(res4).toBeInstanceOf(Book2); expect(res4.createdAt).toBeDefined(); expect(res4.price).toBe('100.00'); diff --git a/tests/EntityManager.postgre.test.ts b/tests/EntityManager.postgre.test.ts index 5190d347819e..c6de3d545b79 100644 --- a/tests/EntityManager.postgre.test.ts +++ b/tests/EntityManager.postgre.test.ts @@ -12,7 +12,6 @@ import { ValidationError, ChangeSetType, wrap, - expr, UniqueConstraintViolationException, TableNotFoundException, NotNullConstraintViolationException, @@ -1532,9 +1531,9 @@ describe('EntityManagerPostgre', () => { const mock = mockLogger(orm, ['query', 'query-params']); const books1 = await orm.em.find(Book2, { - [expr('upper(title)')]: ['B1', 'B2'], + [raw('upper(title)')]: ['B1', 'B2'], author: { - [expr('age::text')]: { $ilike: '%2%' }, + [raw(a => `${a}.age::text`)]: { $ilike: '%2%' }, }, }, { populate: ['perex'] }); expect(books1).toHaveLength(2); @@ -1542,12 +1541,34 @@ describe('EntityManagerPostgre', () => { orm.em.clear(); const books2 = await orm.em.find(Book2, { - [expr('upper(title)')]: raw('upper(?)', ['b2']), + [raw('upper(title)')]: raw('upper(?)', ['b2']), }, { populate: ['perex'] }); expect(books2).toHaveLength(1); expect(mock.mock.calls[1][0]).toMatch(`select "b0".*, "b0".price * 1.19 as "price_taxed" from "book2" as "b0" where "b0"."author_id" is not null and upper(title) = upper('b2')`); }); + test('custom expressions require raw helper', async () => { + await orm.em.insertMany(Author2, [ + { name: 'n1', email: 'e1' }, + { name: 'n2', email: 'e2' }, + { name: 'n3', email: 'e3' }, + ]); + const mock = mockLogger(orm, ['query', 'query-params']); + const res = await orm.em.find(Author2, { + [raw('? = ? union select * from author2; --', [1, 1])]: 1, + }); + expect(res).toHaveLength(3); + + expect(mock.mock.calls[0][0]).toMatch('select "a0".* from "author2" as "a0" where 1 = 1 union select * from author2; --'); + + await expect(orm.em.find(Author2, { + // @ts-expect-error + ['1 = 1 union select * from author2; --']: 1, + })).rejects.toThrow('column a0.1 = 1 union select * from author2; -- does not exist'); + + expect(mock.mock.calls[1][0]).toMatch('select "a0".* from "author2" as "a0" where "a0"."1 = 1 union select * from author2; --" = 1'); + }); + test('insert with raw sql fragment', async () => { const author = orm.em.create(Author2, { id: 1, name: 'name', email: 'email', age: raw('100 + 20 + 3') }); const mock = mockLogger(orm, ['query', 'query-params']); diff --git a/tests/EntityManager.sqlite2.test.ts b/tests/EntityManager.sqlite2.test.ts index 9db7be6aa504..d5a52ade9bae 100644 --- a/tests/EntityManager.sqlite2.test.ts +++ b/tests/EntityManager.sqlite2.test.ts @@ -1,5 +1,14 @@ import type { EntityName } from '@mikro-orm/core'; -import { ArrayCollection, Collection, EntityManager, LockMode, QueryOrder, ValidationError, wrap } from '@mikro-orm/core'; +import { + ArrayCollection, + Collection, + EntityManager, + LockMode, + QueryOrder, + raw, + ValidationError, + wrap, +} from '@mikro-orm/core'; import { MikroORM } from '@mikro-orm/sqlite'; import { initORMSqlite2, mockLogger } from './bootstrap'; import type { IAuthor4, IPublisher4, ITest4 } from './entities-schema'; @@ -916,7 +925,7 @@ describe.each(['sqlite', 'better-sqlite'] as const)('EntityManager (%s)', driver const b1 = await orm.em.findOneOrFail(FooBar4, { name: 'b1' }); expect(b1.virtual).toBeUndefined(); - await orm.em.createQueryBuilder(FooBar4).select(`id, '123' as virtual`).getResultList(); + await orm.em.createQueryBuilder(FooBar4).select(raw(`id, '123' as virtual`)).getResultList(); expect(b1.virtual).toBe('123'); }); diff --git a/tests/QueryBuilder.test.ts b/tests/QueryBuilder.test.ts index 1d9e463b1c35..304cc3a543a1 100644 --- a/tests/QueryBuilder.test.ts +++ b/tests/QueryBuilder.test.ts @@ -1,5 +1,5 @@ import { inspect } from 'util'; -import { expr, LockMode, MikroORM, QueryFlag, QueryOrder, raw, UnderscoreNamingStrategy } from '@mikro-orm/core'; +import { LockMode, MikroORM, QueryFlag, QueryOrder, raw, sql, UnderscoreNamingStrategy } from '@mikro-orm/core'; import { CriteriaNode, QueryBuilder, PostgreSqlDriver } from '@mikro-orm/postgresql'; import { MySqlDriver } from '@mikro-orm/mysql'; import { Address2, Author2, Book2, BookTag2, Car2, CarOwner2, Configuration2, FooBar2, FooBaz2, FooParam2, Publisher2, PublisherType, Test2, User2 } from './entities-sql'; @@ -33,13 +33,13 @@ describe('QueryBuilder', () => { const qb1 = orm.em.createQueryBuilder(Publisher2); qb1.select('*') .where({ name: 'test 123', type: PublisherType.GLOBAL }) - .orderBy({ [`(point(location_latitude, location_longitude) <@> point(${53}, ${9}))`]: 'ASC' }); + .orderBy({ [raw(`(point(location_latitude, location_longitude) <@> point(?, ?))`, [53, 9])]: 'ASC' }); expect(qb1.getFormattedQuery()).toBe('select `e0`.* from `publisher2` as `e0` where `e0`.`name` = \'test 123\' and `e0`.`type` = \'global\' order by (point(location_latitude, location_longitude) <@> point(53, 9)) asc'); const qb2 = orm.em.createQueryBuilder(Publisher2); qb2.select('*') .where({ name: 'test 123', type: PublisherType.GLOBAL }) - .orderBy({ [`(point(location_latitude, location_longitude) <@> point(${53.46}, ${9.90}))`]: 'ASC' }); + .orderBy({ [raw(`(point(location_latitude, location_longitude) <@> point(?, ?))`, [53.46, 9.90])]: 'ASC' }); expect(qb2.getFormattedQuery()).toBe('select `e0`.* from `publisher2` as `e0` where `e0`.`name` = \'test 123\' and `e0`.`type` = \'global\' order by (point(location_latitude, location_longitude) <@> point(53.46, 9.9)) asc'); // trying to modify finalized QB will throw @@ -126,7 +126,7 @@ describe('QueryBuilder', () => { test('select constant expression', async () => { const qb = orm.em.createQueryBuilder(Publisher2); - qb.select('1').where({ id: 123 }); + qb.select(raw('1')).where({ id: 123 }); expect(qb.getQuery()).toEqual('select 1 from `publisher2` as `e0` where `e0`.`id` = ?'); expect(qb.getParams()).toEqual([123]); }); @@ -380,16 +380,26 @@ describe('QueryBuilder', () => { const qb = orm.em.createQueryBuilder(Author2, 'a'); qb.select(['a.*', 'b.*']) .leftJoin('a.books', 'b', { - 'json_contains(`a`.`meta`, ?)': [{ 'b.foo': 'bar' }], - 'json_contains(`a`.`meta`, ?) = ?': [{ 'b.foo': 'bar' }, false], - 'lower(b.bar)': '321', + [raw('json_contains(`b`.`meta`, ?)', [{ 'b.foo': 'bar' }])]: [], + [raw('json_contains(`b`.`meta`, ?) = ?', [{ 'b.foo': 'bar' }, false])]: [], + [raw('lower(??)', ['b.title'])]: '321', }) .where({ 'b.title': 'test 123' }); - const sql = 'select `a`.*, `b`.* from `author2` as `a` ' + - 'left join `book2` as `b` on `a`.`id` = `b`.`author_id` and json_contains(`a`.`meta`, ?) and json_contains(`a`.`meta`, ?) = ? and lower(b.bar) = ? ' + - 'where `b`.`title` = ?'; - expect(qb.getQuery()).toEqual(sql); - expect(qb.getParams()).toEqual(['{"b.foo":"bar"}', '{"b.foo":"bar"}', false, '321', 'test 123']); + expect(qb.getQuery()).toEqual('select `a`.*, `b`.* from `author2` as `a` ' + + 'left join `book2` as `b` ' + + 'on `a`.`id` = `b`.`author_id` ' + + 'and json_contains(`b`.`meta`, ?) ' + + 'and json_contains(`b`.`meta`, ?) = ? ' + + 'and lower(`b`.`title`) = ? ' + + 'where `b`.`title` = ?'); + expect(qb.getParams()).toEqual([{ 'b.foo': 'bar' }, { 'b.foo': 'bar' }, false, '321', 'test 123']); + expect(qb.getFormattedQuery()).toEqual('select `a`.*, `b`.* from `author2` as `a` ' + + 'left join `book2` as `b` ' + + 'on `a`.`id` = `b`.`author_id` ' + + 'and json_contains(`b`.`meta`, \'{\\"b.foo\\":\\"bar\\"}\') ' + + 'and json_contains(`b`.`meta`, \'{\\"b.foo\\":\\"bar\\"}\') = false ' + + 'and lower(`b`.`title`) = \'321\' ' + + "where `b`.`title` = 'test 123'"); }); test('select leftJoin 1:m with multiple conditions', async () => { @@ -417,19 +427,23 @@ describe('QueryBuilder', () => { }, { $and: [ - { 'json_contains(`a`.`meta`, ?)': [{ 'b.foo': 'bar' }] }, - { 'json_contains(`a`.`meta`, ?) = ?': [{ 'b.foo': 'bar' }, false] }, - { 'lower(b.bar)': '321' }, + { [raw('json_contains(`b`.`meta`, ?)', [{ 'b.foo': 'bar' }])]: [] }, + { [raw('json_contains(`b`.`meta`, ?) = ?', [{ 'b.foo': 'bar' }, false])]: [] }, + { [raw('lower(??)', ['b.title'])]: '321' }, ], }, ], }) .where({ 'b.title': 'test 123' }); const sql = 'select `a`.*, `b`.* from `author2` as `a` ' + - 'left join `book2` as `b` on `a`.`id` = `b`.`author_id` and (`b`.`baz` > ? and `b`.`baz` <= ?) and match(`b`.`title`) against (? in boolean mode) and ((`b`.`foo` is null and `b`.`qux` is not null and `b`.`quux` is null and `b`.`baz` = ?) or (`b`.`foo` not in (?, ?) and `b`.`baz` in (?, ?) and `b`.`qux` is not null and `b`.`bar` like ?) or (`b`.`qux` is null and `b`.`bar` regexp ?) or (json_contains(`a`.`meta`, ?) and json_contains(`a`.`meta`, ?) = ? and lower(b.bar) = ?)) ' + + 'left join `book2` as `b` ' + + 'on `a`.`id` = `b`.`author_id` ' + + 'and (`b`.`baz` > ? and `b`.`baz` <= ?) ' + + 'and match(`b`.`title`) against (? in boolean mode) ' + + 'and ((`b`.`foo` is null and `b`.`qux` is not null and `b`.`quux` is null and `b`.`baz` = ?) or (`b`.`foo` not in (?, ?) and `b`.`baz` in (?, ?) and `b`.`qux` is not null and `b`.`bar` like ?) or (`b`.`qux` is null and `b`.`bar` regexp ?) or (json_contains(`b`.`meta`, ?) and json_contains(`b`.`meta`, ?) = ? and lower(`b`.`title`) = ?)) ' + 'where `b`.`title` = ?'; expect(qb.getQuery()).toEqual(sql); - expect(qb.getParams()).toEqual([1, 10, 'test', 0, 0, 1, 2, 3, '%test%', '^(te){1,3}st$', '{"b.foo":"bar"}', '{"b.foo":"bar"}', false, '321', 'test 123']); + expect(qb.getParams()).toEqual([1, 10, 'test', 0, 0, 1, 2, 3, '%test%', '^(te){1,3}st$', { 'b.foo': 'bar' }, { 'b.foo': 'bar' }, false, '321', 'test 123']); }); test('select leftJoin m:n owner', async () => { @@ -505,14 +519,18 @@ describe('QueryBuilder', () => { test('select with custom expression', async () => { const qb1 = orm.em.createQueryBuilder(Book2); - qb1.select('*').where({ 'json_contains(`e0`.`meta`, ?)': [{ foo: 'bar' }] }); + qb1.select('*').where({ + [raw('json_contains(`e0`.`meta`, ?)', [{ foo: 'bar' }])]: [], + }); expect(qb1.getQuery()).toEqual('select `e0`.*, `e0`.price * 1.19 as `price_taxed` from `book2` as `e0` where json_contains(`e0`.`meta`, ?)'); - expect(qb1.getParams()).toEqual(['{"foo":"bar"}']); + expect(qb1.getParams()).toEqual([{ foo: 'bar' }]); const qb2 = orm.em.createQueryBuilder(Book2); - qb2.select('*').where({ [expr(a => `json_contains(\`${a}\`.\`meta\`, ?) = ?`)]: [{ foo: 'baz' }, false] }); + qb2.select('*').where({ + [raw(a => `json_contains(\`${a}\`.??, ?) = ?`, ['meta', { foo: 'baz' }, false])]: [], + }); expect(qb2.getQuery()).toEqual('select `e0`.*, `e0`.price * 1.19 as `price_taxed` from `book2` as `e0` where json_contains(`e0`.`meta`, ?) = ?'); - expect(qb2.getParams()).toEqual(['{"foo":"baz"}', false]); + expect(qb2.getParams()).toEqual([{ foo: 'baz' }, false]); }); test('select with prototype-less object', async () => { @@ -636,21 +654,17 @@ describe('QueryBuilder', () => { test('GH #1668', async () => { const qb1 = orm.em.createQueryBuilder(Author2, 'a'); - qb1.select([ - 'floor(`a`.`age`) as `books_total`', - ]) - .groupBy('booksTotal') - .orderBy({ booksTotal: QueryOrder.ASC }); + qb1.select(raw('floor(`a`.`age`) as `books_total`')) + .groupBy('booksTotal') + .orderBy({ booksTotal: QueryOrder.ASC }); expect(qb1.getQuery()).toEqual('select floor(`a`.`age`) as `books_total` from `author2` as `a` group by `books_total` order by `books_total` asc'); expect(qb1.getParams()).toEqual([]); const qb2 = orm.em.createQueryBuilder(Author2, 'a'); - qb2.select([ - 'floor(`a`.`age`) as `code`', - ]) - .groupBy('code') - .orderBy({ code: QueryOrder.ASC }); + qb2.select(raw('floor(`a`.`age`) as `code`')) + .groupBy('code') + .orderBy({ code: QueryOrder.ASC }); expect(qb2.getQuery()).toEqual('select floor(`a`.`age`) as `code` from `author2` as `a` group by `code` order by `code` asc'); expect(qb2.getParams()).toEqual([]); @@ -836,7 +850,7 @@ describe('QueryBuilder', () => { test('select distinct id with left join', async () => { const qb = orm.em.createQueryBuilder(BookTag2, 't'); - qb.select(['distinct `b`.`uuid_pk`', 'b.*', 't.*']) + qb.select([raw('distinct `b`.`uuid_pk`'), 'b.*', 't.*']) .leftJoin('t.books', 'b') .where({ 'b.title': 'test 123' }); const sql = 'select distinct `b`.`uuid_pk`, `b`.*, `t`.* from `book_tag2` as `t` ' + @@ -872,12 +886,12 @@ describe('QueryBuilder', () => { 'left join `book2` as `b` on `e1`.`book2_uuid_pk` = `b`.`uuid_pk` ' + 'where (((b.title = ? or b.title = ?) and (1 = 1)) or (1 = 2))'; expect(qb.getQuery()).toEqual(sql); - expect(qb.getParams()).toEqual(['test 123', 'lol 321']); + expect(qb.getParams()).toEqual(['test 123', 'lol 321' ]); }); test('select with group by and having', async () => { const qb = orm.em.createQueryBuilder(BookTag2, 't'); - qb.select(['b.*', 't.*', 'count(t.id) as tags']) + qb.select(['b.*', 't.*', raw('count(t.id) as tags')]) .leftJoin('t.books', 'b') .where('b.title = ? or b.title = ?', ['test 123', 'lol 321']) .groupBy(['b.uuid', 't.id']) @@ -894,11 +908,11 @@ describe('QueryBuilder', () => { test('select with group by and having with object', async () => { const qb = orm.em.createQueryBuilder(BookTag2, 't'); - qb.select(['b.*', 't.*', 'count(t.id) as tags']) + qb.select(['b.*', 't.*', raw('count(t.id) as tags')]) .leftJoin('t.books', 'b') .where('b.title = ? or b.title = ?', ['test 123', 'lol 321']) .groupBy(['b.uuid', 't.id']) - .having({ $or: [{ 'b.uuid': '...', 'count(t.id)': { $gt: 0 } }, { 'b.title': 'my title' }] }); + .having({ $or: [{ 'b.uuid': '...', [raw('count(t.id)')]: { $gt: 0 } }, { 'b.title': 'my title' }] }); const sql = 'select `b`.*, `t`.*, count(t.id) as tags from `book_tag2` as `t` ' + 'left join `book2_tags` as `e1` on `t`.`id` = `e1`.`book_tag2_id` ' + 'left join `book2` as `b` on `e1`.`book2_uuid_pk` = `b`.`uuid_pk` ' + @@ -912,15 +926,15 @@ describe('QueryBuilder', () => { test('select with operator (and)', async () => { const qb = orm.em.createQueryBuilder(Test2); qb.select('*').where({ $and: [ - { id: { $in: [1, 2, 7] } }, - { id: { $nin: [3, 4] } }, - { id: { $gt: 5 } }, - { id: { $lt: 10 } }, - { id: { $gte: 7 } }, - { id: { $lte: 8 } }, - { id: { $ne: 9 } }, - { $not: { id: { $eq: 10 } } }, - ] }); + { id: { $in: [1, 2, 7] } }, + { id: { $nin: [3, 4] } }, + { id: { $gt: 5 } }, + { id: { $lt: 10 } }, + { id: { $gte: 7 } }, + { id: { $lte: 8 } }, + { id: { $ne: 9 } }, + { $not: { id: { $eq: 10 } } }, + ] }); expect(qb.getQuery()).toEqual('select `e0`.* from `test2` as `e0` ' + 'where `e0`.`id` in (?, ?, ?) ' + 'and `e0`.`id` not in (?, ?) ' + @@ -936,15 +950,15 @@ describe('QueryBuilder', () => { test('select with operator (or)', async () => { const qb = orm.em.createQueryBuilder(Test2); qb.select('*').where({ $or: [ - { id: { $in: [1, 2, 7] } }, - { id: { $nin: [3, 4] } }, - { id: { $gt: 5 } }, - { id: { $lt: 10 } }, - { id: { $gte: 7 } }, - { id: { $lte: 8 } }, - { id: { $ne: 9 } }, - { $not: { id: { $eq: 10 } } }, - ] }); + { id: { $in: [1, 2, 7] } }, + { id: { $nin: [3, 4] } }, + { id: { $gt: 5 } }, + { id: { $lt: 10 } }, + { id: { $gte: 7 } }, + { id: { $lte: 8 } }, + { id: { $ne: 9 } }, + { $not: { id: { $eq: 10 } } }, + ] }); expect(qb.getQuery()).toEqual('select `e0`.* from `test2` as `e0` ' + 'where (`e0`.`id` in (?, ?, ?) ' + 'or `e0`.`id` not in (?, ?) ' + @@ -960,14 +974,14 @@ describe('QueryBuilder', () => { test('select with smart query conditions', async () => { const qb = orm.em.createQueryBuilder(Test2); qb.select('*').where({ version: { - $gt: 1, - $lt: 2, - $gte: 3, - $lte: 4, - $ne: 5, - $in: [6, 7], - $nin: [8, 9], - } }); + $gt: 1, + $lt: 2, + $gte: 3, + $lte: 4, + $ne: 5, + $in: [6, 7], + $nin: [8, 9], + } }); expect(qb.getQuery()).toEqual('select `e0`.* from `test2` as `e0` ' + 'where `e0`.`version` > ? ' + 'and `e0`.`version` < ? ' + @@ -1489,7 +1503,7 @@ describe('QueryBuilder', () => { test('update query with JSON type and raw value', async () => { const qb = orm.em.createQueryBuilder(Book2); - const meta = raw(`jsonb_set(payload, '$.{consumed}', ?)`, [123]); + const meta = sql`jsonb_set(payload, '$.{consumed}', ${123})`; qb.update({ meta }).where({ uuid: '456' }); expect(qb.getFormattedQuery()).toEqual('update `book2` set `meta` = jsonb_set(payload, \'$.{consumed}\', 123) where `uuid_pk` = \'456\''); }); @@ -1899,15 +1913,15 @@ describe('QueryBuilder', () => { '((`a`.`email` = ? or (`a`.`name` in (?) and `a`.`name` != ?) or `a`.`email` = ?)) or ' + 'not ((`a`.`name` = ? or `a`.`email` = ?)) or ' + '(' + - '(' + - '((' + - '(`a`.`name` = ? and `a`.`email` = ?) or ' + - '(`a`.`name` = ? and `a`.`email` = ?)' + - ')) or ' + - '(`a`.`name` = ? and `a`.`email` = ?)' + - ')' + + '(' + + '((' + + '(`a`.`name` = ? and `a`.`email` = ?) or ' + + '(`a`.`name` = ? and `a`.`email` = ?)' + + ')) or ' + + '(`a`.`name` = ? and `a`.`email` = ?)' + + ')' + ')' + - ')'); + ')'); }); test('select fk by operator should not trigger auto-joining', async () => { @@ -1952,7 +1966,7 @@ describe('QueryBuilder', () => { qb1.select('*').where({ $or: [ { author: { name: 'test' } }, - { author: { [expr(a => `lower(${a}.name)`)]: 'wut' } }, + { author: { [raw(a => `lower(${a}.name)`)]: 'wut' } }, ], }); expect(qb1.getQuery()).toEqual('select `a`.*, `a`.price * 1.19 as `price_taxed` ' + @@ -1969,13 +1983,13 @@ describe('QueryBuilder', () => { test('order by virtual property', async () => { const qb1 = orm.em.createQueryBuilder(Author2, 'a'); - qb1.select(['*', '"1" as code']).where({ $in: [1, 2] }).orderBy({ code: 'asc' }); + qb1.select(['*', sql`"1" as code`]).where({ $in: [1, 2] }).orderBy({ code: 'asc' }); expect(qb1.getQuery()).toEqual('select `a`.*, "1" as code from `author2` as `a` where `a`.`id` in (?, ?) order by `code` asc'); }); test('having with virtual property', async () => { const qb1 = orm.em.createQueryBuilder(Author2, 'a'); - qb1.select(['*', '"1" as code']).where({ $in: [1, 2] }).having({ + qb1.select(['*', sql`"1" as code`]).where({ $in: [1, 2] }).having({ code: { $gte: 'c' }, $or: [{ code: { $gt: 'c' } }, { id: { $lt: 3 } }], }); @@ -2132,7 +2146,7 @@ describe('QueryBuilder', () => { test('order by custom expression', async () => { const qb = orm.em.createQueryBuilder(Publisher2); - qb.select('*').orderBy({ 'length(name)': QueryOrder.DESC, 'type': QueryOrder.ASC }); + qb.select('*').orderBy({ [sql`length(name)`]: QueryOrder.DESC, type: QueryOrder.ASC }); expect(qb.getQuery()).toEqual('select `e0`.* from `publisher2` as `e0` order by length(name) desc, `e0`.`type` asc'); }); @@ -2140,11 +2154,13 @@ describe('QueryBuilder', () => { const qb141 = orm.em.createQueryBuilder(Book2).update({ meta: { items: 3 } }).where({ $and: [ { uuid: 'b47f1cca-90ca-11ec-99e0-42010a5d800c' }, - { $or: [ + { + $or: [ { meta: null }, { meta: { $eq: null } }, { meta: { time: { $lt: 1646147306 } } }, - ] }, + ], + }, ], }); expect(qb141.getFormattedQuery()).toBe('update `book2` set `meta` = \'{\\"items\\":3}\' ' + @@ -2506,7 +2522,7 @@ describe('QueryBuilder', () => { expect(qb4.getParams()).toEqual(['test']); const qb5 = pg.em.createQueryBuilder(Book2, 'b').select('b.author').where({ price: { $gt: 100 } }); - const qb6 = pg.em.createQueryBuilder(Author2, 'a').select('*').where(`id in (${qb5.getFormattedQuery()})`); + const qb6 = pg.em.createQueryBuilder(Author2, 'a').select('*').where(raw(`id in (${qb5.getFormattedQuery()})`)); expect(qb6.getQuery()).toEqual('select "a".* from "author2" as "a" where (id in (select "b"."author_id" from "book2" as "b" where "b"."price" > 100))'); expect(qb6.getParams()).toEqual([]); @@ -2558,10 +2574,10 @@ describe('QueryBuilder', () => { $and: [ { uuid: 'b47f1cca-90ca-11ec-99e0-42010a5d800c' }, { $or: [ - { meta: null }, - { meta: { $eq: null } }, - { meta: { time: { $lt: 1646147306 } } }, - ] }, + { meta: null }, + { meta: { $eq: null } }, + { meta: { time: { $lt: 1646147306 } } }, + ] }, ], }); expect(qb141.getFormattedQuery()).toBe('update "book2" set "meta" = \'{"items":3}\' ' + diff --git a/tests/entities-sql/Book2.ts b/tests/entities-sql/Book2.ts index 01bfc2f5ec58..e63be96567d4 100644 --- a/tests/entities-sql/Book2.ts +++ b/tests/entities-sql/Book2.ts @@ -17,6 +17,7 @@ import { ref, rel, t, + sql, } from '@mikro-orm/core'; import { Publisher2 } from './Publisher2'; import { Author2 } from './Author2'; @@ -25,7 +26,7 @@ import { Test2 } from './Test2'; @Entity() @Filter({ name: 'expensive', cond: { price: { $gt: 1000 } } }) -@Filter({ name: 'long', cond: { 'length(perex)': { $gt: 10000 } } }) +@Filter({ name: 'long', cond: () => ({ [sql`length(perex)`]: { $gt: 10000 } }) }) @Filter({ name: 'hasAuthor', cond: { author: { $ne: null } }, default: true }) @Filter({ name: 'writtenBy', cond: args => ({ author: { name: args.name } }) }) export class Book2 { diff --git a/tests/features/custom-types/json-properties.test.ts b/tests/features/custom-types/json-properties.test.ts index 420842c156e9..fbf1afb1838c 100644 --- a/tests/features/custom-types/json-properties.test.ts +++ b/tests/features/custom-types/json-properties.test.ts @@ -1,4 +1,4 @@ -import { MikroORM, Entity, PrimaryKey, Property, SimpleLogger, Utils, IDatabaseDriver } from '@mikro-orm/core'; +import { MikroORM, Entity, PrimaryKey, Property, SimpleLogger, Utils, IDatabaseDriver, sql } from '@mikro-orm/core'; import { mockLogger } from '../../helpers'; import { PLATFORMS } from '../../bootstrap'; @@ -53,10 +53,10 @@ describe.each(Utils.keys(options))('JSON properties [%s]', type => { const res2 = await orm.em.findOneOrFail(User, { value: true }); expect(res2.value).toBe(true); - // this should work in v6, once the `raw()` helper refactor will be merged - // await orm.em.insert(User, { value: [1, 2, 3] }); - // const res3 = await orm.em.findOneOrFail(User, { value: { $eq: [1, 2, 3] } }); - // expect(res3.value).toEqual([1, 2, 3]); + await orm.em.insert(User, { value: [1, 2, 3] }); + const val = type === 'mysql' ? sql`json_array(1, 2, 3)` : [1, 2, 3]; + const res3 = await orm.em.findOneOrFail(User, { value: { $eq: val } }); + expect(res3.value).toEqual([1, 2, 3]); }); test('em.insert() with object value', async () => { diff --git a/tests/features/embeddables/embedded-entities.mongo.test.ts b/tests/features/embeddables/embedded-entities.mongo.test.ts index 46ee28bfd833..f33641665dc1 100644 --- a/tests/features/embeddables/embedded-entities.mongo.test.ts +++ b/tests/features/embeddables/embedded-entities.mongo.test.ts @@ -1,5 +1,5 @@ import type { Dictionary, Platform } from '@mikro-orm/core'; -import { Embeddable, Embedded, Entity, EntitySchema, expr, PrimaryKey, Property, ReferenceKind, SerializedPrimaryKey, Type } from '@mikro-orm/core'; +import { Embeddable, Embedded, Entity, EntitySchema, PrimaryKey, Property, ReferenceKind, SerializedPrimaryKey, Type } from '@mikro-orm/core'; import { MikroORM, ObjectId, MongoConnection, MongoPlatform } from '@mikro-orm/mongodb'; import { mockLogger } from '../../helpers'; @@ -327,7 +327,7 @@ describe('embedded entities in mongo', () => { expect(u4).toBe(u1); const u5 = await orm.em.findOneOrFail(User, { address4: { - [expr('$exists')]: true, + $exists: true, }, }); expect(u5).toBe(u1); diff --git a/tests/features/embeddables/embedded-entities.postgres.test.ts b/tests/features/embeddables/embedded-entities.postgres.test.ts index 950402f9f2b9..8677baf19010 100644 --- a/tests/features/embeddables/embedded-entities.postgres.test.ts +++ b/tests/features/embeddables/embedded-entities.postgres.test.ts @@ -1,4 +1,4 @@ -import { Embeddable, Embedded, Entity, expr, MikroORM, PrimaryKey, Property, ReferenceKind, t } from '@mikro-orm/core'; +import { Embeddable, Embedded, Entity, raw, MikroORM, PrimaryKey, Property, ReferenceKind, t } from '@mikro-orm/core'; import { PostgreSqlDriver } from '@mikro-orm/postgresql'; import { mockLogger } from '../../helpers'; @@ -402,10 +402,10 @@ describe('embedded entities in postgresql', () => { const mock = mockLogger(orm); const r = await orm.em.find(User, { - [expr('(address4->>\'street\')::text != \'\'')]: [], - [expr('lower((address4->>\'city\')::text) = ?')]: ['prague'], - [expr('(address4->>?)::text = ?')]: ['city', 'Prague'], - [expr('(address4->>?)::text')]: ['postalCode', '12000'], + [raw('(address4->>\'street\')::text != \'\'')]: [], + [raw('lower((address4->>\'city\')::text) = ?', ['prague'])]: [], + [raw('(address4->>?)::text = ?', ['city', 'Prague'])]: [], + [raw('(address4->>?)::text', ['postalCode'])]: '12000', }); expect(r[0]).toBeInstanceOf(User); expect(r[0].address4).toBeInstanceOf(Address1); diff --git a/tests/features/entity-assigner/EntityAssigner.mongo.test.ts b/tests/features/entity-assigner/EntityAssigner.mongo.test.ts index 4fc04e5b491a..8f8e172b281f 100644 --- a/tests/features/entity-assigner/EntityAssigner.mongo.test.ts +++ b/tests/features/entity-assigner/EntityAssigner.mongo.test.ts @@ -1,5 +1,5 @@ import type { EntityData, MikroORM } from '@mikro-orm/core'; -import { assign, expr, wrap } from '@mikro-orm/core'; +import { assign, wrap } from '@mikro-orm/core'; import type { MongoDriver } from '@mikro-orm/mongodb'; import { ObjectId } from '@mikro-orm/mongodb'; import { Author, Book, BookTag } from '../../entities'; @@ -19,7 +19,8 @@ describe('EntityAssignerMongo', () => { await orm.em.persistAndFlush(book); expect(book.title).toBe('Book2'); expect(book.author).toBe(jon); - book.assign({ title: 'Better Book2 1', author: god, [expr('notExisting')]: true }); + // @ts-expect-error unknown property + book.assign({ title: 'Better Book2 1', author: god, notExisting: true }); expect(book.author).toBe(god); expect((book as any).notExisting).toBe(true); await orm.em.persistAndFlush(god); diff --git a/tests/features/virtual-entities/virtual-entities.sqlite.test.ts b/tests/features/virtual-entities/virtual-entities.sqlite.test.ts index 7c14a0d1bb04..68958b856c71 100644 --- a/tests/features/virtual-entities/virtual-entities.sqlite.test.ts +++ b/tests/features/virtual-entities/virtual-entities.sqlite.test.ts @@ -1,4 +1,4 @@ -import { EntitySchema, MikroORM, ReferenceKind } from '@mikro-orm/core'; +import { EntitySchema, MikroORM, raw, ReferenceKind } from '@mikro-orm/core'; import type { EntityManager } from '@mikro-orm/better-sqlite'; import { mockLogger } from '../../bootstrap'; import type { IAuthor4 } from '../../entities-schema'; @@ -42,7 +42,7 @@ const BookWithAuthor = new EntitySchema({ name: 'BookWithAuthor', expression: (em: EntityManager) => { return em.createQueryBuilder(Book4, 'b') - .select(['b.title', 'a.name as author_name', 'group_concat(t.name) as tags']) + .select(['b.title', 'a.name as author_name', raw('group_concat(t.name) as tags')]) .join('b.author', 'a') .join('b.tags', 't') .groupBy('b.id');