Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): require explicitly marked raw queries via raw() helper #4197

Merged
merged 1 commit into from Apr 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
49 changes: 49 additions & 0 deletions 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)`]: [] });
```
6 changes: 3 additions & 3 deletions 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';
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/metadata/MetadataDiscovery.ts
Expand Up @@ -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 {

Expand Down Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/platforms/Platform.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 0 additions & 73 deletions packages/core/src/utils/QueryHelper.ts
Expand Up @@ -284,76 +284,3 @@ interface ProcessWhereOptions<T> {
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<T = unknown>(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<R = any>(sql: string, params?: unknown[] | Dictionary<unknown>): 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;
}
165 changes: 165 additions & 0 deletions 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<string, RawQueryFragment>();
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<T extends object = any, R = any>(sql: EntityKey<T> | EntityKey<T>[] | AnyString | ((alias: string) => string) | RawQueryFragment, params?: unknown[] | Dictionary<unknown>): 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);
}
2 changes: 1 addition & 1 deletion packages/core/src/utils/Utils.ts
Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Expand Up @@ -8,3 +8,4 @@ export * from './QueryHelper';
export * from './NullHighlighter';
export * from './EntityComparator';
export * from './AbstractSchemaGenerator';
export * from './RawQueryFragment';