Skip to content

Commit

Permalink
feat(core): add sql.now(), sql.lower() and sql.upper() functions (
Browse files Browse the repository at this point in the history
#5044)

### `sql.now()`

When you want to define a default value for a datetime column, you can
use the `sql.now()` function. It resolves to `current_timestamp` SQL
function, and accepts a `length` parameter.

```ts
@Property({ default: sql.now() })
createdAt: Date & Opt;
```

### `sql.lower()` and `sql.upper()`

To convert a key to lowercase or uppercase, you can use the
`sql.lower()` and `sql.upper()` functions

```ts
const books = await orm.em.find(Book, {
  [sql.upper('title')]: 'TITLE',
});
```
  • Loading branch information
B4nan committed Dec 23, 2023
1 parent 5132e5e commit 016fe63
Show file tree
Hide file tree
Showing 27 changed files with 261 additions and 134 deletions.
19 changes: 19 additions & 0 deletions docs/docs/raw-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ When you want to refer to a column, you can use the `sql.ref()` function:
await em.find(User, { foo: sql`bar` });
```

### `sql.now()`

When you want to define a default value for a datetime column, you can use the `sql.now()` function. It resolves to `current_timestamp` SQL function, and accepts a `length` parameter.

```ts
@Property({ default: sql.now() })
createdAt: Date & Opt;
```

### `sql.lower()` and `sql.upper()`

To convert a key to lowercase or uppercase, you can use the `sql.lower()` and `sql.upper()` functions

```ts
const books = await orm.em.find(Book, {
[sql.upper('title')]: 'TITLE',
});
```

### Aliasing

To select a raw fragment, we need to alias it. For that, we can use ```sql`(select 1 + 1)`.as('<alias>')```.
53 changes: 50 additions & 3 deletions packages/core/src/metadata/MetadataDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,19 @@ import { EntitySchema } from './EntitySchema';
import { Cascade, type EventType, ReferenceKind } from '../enums';
import { MetadataError } from '../errors';
import type { Platform } from '../platforms';
import { ArrayType, BigIntType, BlobType, EnumArrayType, JsonType, t, Type, Uint8ArrayType } from '../types';
import {
ArrayType,
BigIntType,
BlobType,
EnumArrayType,
JsonType,
t,
Type,
Uint8ArrayType,
UnknownType,
} from '../types';
import { colors } from '../logging/colors';
import { raw } from '../utils/RawQueryFragment';
import { raw, RawQueryFragment } from '../utils/RawQueryFragment';
import type { Logger } from '../logging/Logger';

export class MetadataDiscovery {
Expand Down Expand Up @@ -126,6 +136,8 @@ export class MetadataDiscovery {

for (const meta of filtered) {
for (const prop of Object.values(meta.properties)) {
this.initDefaultValue(prop);
this.inferTypeFromDefault(prop);
this.initColumnType(prop);
}
}
Expand Down Expand Up @@ -385,6 +397,17 @@ export class MetadataDiscovery {
.forEach(k => delete (prop as Dictionary)[k]);
});

copy.props
.filter(prop => prop.default)
.forEach(prop => {
const raw = RawQueryFragment.getKnownFragment(prop.default as string);

if (raw) {
prop.defaultRaw ??= this.platform.formatQuery(raw.sql, raw.params);
delete prop.default;
}
});

([
'prototype', 'props', 'referencingProperties', 'propertyOrder', 'relations',
'concurrencyCheckKeys', 'checks',
Expand Down Expand Up @@ -558,6 +581,7 @@ export class MetadataDiscovery {
this.initNullability(prop);
this.applyNamingStrategy(meta, prop);
this.initDefaultValue(prop);
this.inferTypeFromDefault(prop);
this.initVersionProperty(meta, prop);
this.initCustomType(meta, prop);
this.initColumnType(prop);
Expand Down Expand Up @@ -1137,6 +1161,7 @@ export class MetadataDiscovery {
const entity1 = new (meta.class as Constructor<any>)();
const entity2 = new (meta.class as Constructor<any>)();


// we compare the two values by reference, this will discard things like `new Date()` or `Date.now()`
if (this.config.get('discovery').inferDefaultValues && prop.default === undefined && entity1[prop.name] != null && entity1[prop.name] === entity2[prop.name] && entity1[prop.name] !== now) {
prop.default ??= entity1[prop.name];
Expand All @@ -1162,6 +1187,12 @@ export class MetadataDiscovery {
}

let val = prop.default;
const raw = RawQueryFragment.getKnownFragment(val as string);

if (raw) {
prop.defaultRaw = this.platform.formatQuery(raw.sql, raw.params);
return;
}

if (prop.customType instanceof ArrayType && Array.isArray(prop.default)) {
val = prop.customType.convertToDatabaseValue(prop.default, this.platform)!;
Expand All @@ -1170,6 +1201,22 @@ export class MetadataDiscovery {
prop.defaultRaw = typeof val === 'string' ? `'${val}'` : '' + val;
}

private inferTypeFromDefault(prop: EntityProperty): void {
if ((prop.defaultRaw == null && prop.default == null) || prop.type !== 'any') {
return;
}

switch (typeof prop.default) {
case 'string': prop.type = prop.runtimeType = 'string'; break;
case 'number': prop.type = prop.runtimeType = 'number'; break;
case 'boolean': prop.type = prop.runtimeType = 'boolean'; break;
}

if (prop.defaultRaw?.startsWith('current_timestamp')) {
prop.type = prop.runtimeType = 'Date';
}
}

private initVersionProperty(meta: EntityMetadata, prop: EntityProperty): void {
if (prop.version) {
this.initDefaultValue(prop);
Expand Down Expand Up @@ -1265,7 +1312,7 @@ export class MetadataDiscovery {
}
}

if (prop.kind === ReferenceKind.SCALAR) {
if (prop.kind === ReferenceKind.SCALAR && !(mappedType instanceof UnknownType)) {
prop.columnTypes ??= [mappedType.getColumnType(prop, this.platform)];

// use only custom types provided by user, we don't need to use the ones provided by ORM,
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/platforms/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,49 @@ export abstract class Platform {
return value;
}

formatQuery(sql: string, params: readonly any[]): string {
if (params.length === 0) {
return sql;
}

// fast string replace without regexps
let j = 0;
let pos = 0;
let ret = '';

if (sql[0] === '?') {
if (sql[1] === '?') {
ret += this.quoteIdentifier(params[j++]);
pos = 2;
} else {
ret += this.quoteValue(params[j++]);
pos = 1;
}
}

while (pos < sql.length) {
const idx = sql.indexOf('?', pos + 1);

if (idx === -1) {
ret += sql.substring(pos, sql.length);
break;
}

if (sql.substring(idx - 1, idx + 1) === '\\?') {
ret += sql.substring(pos, idx - 1) + '?';
pos = idx + 1;
} else if (sql.substring(idx, idx + 2) === '??') {
ret += sql.substring(pos, idx) + this.quoteIdentifier(params[j++]);
pos = idx + 2;
} else {
ret += sql.substring(pos, idx) + this.quoteValue(params[j++]);
pos = idx + 1;
}
}

return ret;
}

cloneEmbeddable<T>(data: T): T {
const copy = clone(data);
// tag the copy so we know it should be stringified when quoting (so we know how to treat JSON arrays)
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/utils/RawQueryFragment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { inspect } from 'util';
import { Utils } from './Utils';
import type { Dictionary, EntityKey, AnyString } from '../typings';
import type { AnyString, Dictionary, EntityKey } from '../typings';

export class RawQueryFragment {

Expand Down Expand Up @@ -178,4 +178,15 @@ export function sql(sql: readonly string[], ...values: unknown[]) {
}, ''), values);
}

export function createSqlFunction<T extends object, R = string>(func: string, key: string | ((alias: string) => string)): R {
if (typeof key === 'string') {
return raw<T, R>(`${func}(${key})`);
}

return raw<T, R>(a => `${func}(${(key(a))})`);
}

sql.ref = <T extends object>(...keys: string[]) => raw<T, RawQueryFragment>('??', [keys.join('.')]);
sql.now = (length?: number) => raw<Date, string>('current_timestamp' + (length == null ? '' : `(${length})`));
sql.lower = <T extends object>(key: string | ((alias: string) => string)) => createSqlFunction('lower', key);
sql.upper = <T extends object>(key: string | ((alias: string) => string)) => createSqlFunction('upper', key);
43 changes: 0 additions & 43 deletions packages/knex/src/AbstractSqlPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,49 +52,6 @@ export abstract class AbstractSqlPlatform extends Platform {
return escape(value, true, this.timezone);
}

formatQuery(sql: string, params: readonly any[]): string {
if (params.length === 0) {
return sql;
}

// fast string replace without regexps
let j = 0;
let pos = 0;
let ret = '';

if (sql[0] === '?') {
if (sql[1] === '?') {
ret += this.quoteIdentifier(params[j++]);
pos = 2;
} else {
ret += this.quoteValue(params[j++]);
pos = 1;
}
}

while (pos < sql.length) {
const idx = sql.indexOf('?', pos + 1);

if (idx === -1) {
ret += sql.substring(pos, sql.length);
break;
}

if (sql.substring(idx - 1, idx + 1) === '\\?') {
ret += sql.substring(pos, idx - 1) + '?';
pos = idx + 1;
} else if (sql.substring(idx, idx + 2) === '??') {
ret += sql.substring(pos, idx) + this.quoteIdentifier(params[j++]);
pos = idx + 2;
} else {
ret += sql.substring(pos, idx) + this.quoteValue(params[j++]);
pos = idx + 1;
}
}

return ret;
}

override getSearchJsonPropertySQL(path: string, type: string, aliased: boolean): string {
return this.getSearchJsonPropertyKey(path.split('->'), type, aliased);
}
Expand Down
21 changes: 11 additions & 10 deletions packages/knex/src/query/QueryBuilderHelper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Knex } from 'knex';
import { inspect } from 'util';
import {
ALIAS_REPLACEMENT,
ALIAS_REPLACEMENT_RE,
type Dictionary,
type EntityData,
Expand Down Expand Up @@ -260,11 +261,11 @@ export class QueryBuilderHelper {
join.cond[`${alias}.${typeProperty}`] = join.prop.targetMeta!.discriminatorValue;
}

Object.keys(join.cond).forEach(key => {
const needsPrefix = key.includes('.') || Utils.isOperator(key) || RawQueryFragment.isKnownFragment(key);
const newKey = needsPrefix ? key : `${join.alias}.${key}`;
conditions.push(this.processJoinClause(newKey, join.cond[key], params));
});
for (const key of Object.keys(join.cond)) {
const hasPrefix = key.includes('.') || Utils.isOperator(key) || RawQueryFragment.isKnownFragment(key);
const newKey = hasPrefix ? key : `${join.alias}.${key}`;
conditions.push(this.processJoinClause(newKey, join.cond[key], join.alias, params));
}

let sql = method + ' ';

Expand All @@ -284,10 +285,10 @@ export class QueryBuilderHelper {
});
}

private processJoinClause(key: string, value: unknown, params: Knex.Value[], operator = '$eq'): string {
private processJoinClause(key: string, value: unknown, alias: string, params: Knex.Value[], operator = '$eq'): string {
if (Utils.isGroupOperator(key) && Array.isArray(value)) {
const parts = value.map(sub => {
return this.wrapQueryGroup(Object.keys(sub).map(k => this.processJoinClause(k, sub[k], params)));
return this.wrapQueryGroup(Object.keys(sub).map(k => this.processJoinClause(k, sub[k], alias, params)));
});
return this.wrapQueryGroup(parts, key);
}
Expand All @@ -302,13 +303,13 @@ export class QueryBuilderHelper {
}

if (Utils.isOperator(key, false) && Utils.isPlainObject(value)) {
const parts = Object.keys(value).map(k => this.processJoinClause(k, (value as Dictionary)[k], params, key));
const parts = Object.keys(value).map(k => this.processJoinClause(k, (value as Dictionary)[k], alias, params, key));

return key === '$not' ? `not ${this.wrapQueryGroup(parts)}` : this.wrapQueryGroup(parts);
}

if (Utils.isPlainObject(value) && Object.keys(value).every(k => Utils.isOperator(k, false))) {
const parts = Object.keys(value).map(op => this.processJoinClause(key, (value as Dictionary)[op], params, op));
const parts = Object.keys(value).map(op => this.processJoinClause(key, (value as Dictionary)[op], alias, params, op));

return this.wrapQueryGroup(parts);
}
Expand Down Expand Up @@ -345,7 +346,7 @@ export class QueryBuilderHelper {
const rawField = RawQueryFragment.getKnownFragment(key);

if (rawField) {
let sql = rawField.sql;
let sql = rawField.sql.replaceAll(ALIAS_REPLACEMENT, alias);
params.push(...rawField.params as Knex.Value[]);
params.push(...Utils.asArray(value) as Knex.Value[]);

Expand Down
8 changes: 7 additions & 1 deletion packages/knex/src/schema/SchemaHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Knex } from 'knex';
import { BigIntType, EnumType, Utils, type Connection, type Dictionary } from '@mikro-orm/core';
import { BigIntType, EnumType, Utils, type Connection, type Dictionary, RawQueryFragment } from '@mikro-orm/core';
import type { AbstractSqlConnection } from '../AbstractSqlConnection';
import type { AbstractSqlPlatform } from '../AbstractSqlPlatform';
import type { CheckDef, Column, IndexDef, Table, TableDifference } from '../typings';
Expand Down Expand Up @@ -259,6 +259,12 @@ export abstract class SchemaHelper {
return defaultValue;
}

const raw = RawQueryFragment.getKnownFragment(defaultValue);

if (raw) {
return this.platform.formatQuery(raw.sql, raw.params);
}

const genericValue = defaultValue.replace(/\(\d+\)/, '(?)').toLowerCase();
const norm = defaultValues[genericValue];

Expand Down
4 changes: 2 additions & 2 deletions packages/postgresql/src/PostgreSqlSchemaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,8 @@ export class PostgreSqlSchemaHelper extends SchemaHelper {
}

override normalizeDefaultValue(defaultValue: string, length: number) {
if (!defaultValue) {
return defaultValue;
if (!defaultValue || typeof defaultValue as unknown !== 'string') {
return super.normalizeDefaultValue(defaultValue, length, PostgreSqlSchemaHelper.DEFAULT_VALUES);
}

const match = defaultValue.match(/^'(.*)'::(.*)$/);
Expand Down
22 changes: 21 additions & 1 deletion packages/reflection/src/TsMorphMetadataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,28 @@ export class TsMorphMetadataProvider extends MetadataProvider {
return prop.type;
}

private cleanUpTypeTags(type: string): string {
const genericTags = [/Opt<(.*?)>/, /Hidden<(.*?)>/];
const intersectionTags = [
'{ __optional?: 1 | undefined; }',
'{ __hidden?: 1 | undefined; }',
];

for (const tag of genericTags) {
type = type.replace(tag, '$1');
}

for (const tag of intersectionTags) {
type = type.replace(' & ' + tag, '');
type = type.replace(tag + ' & ', '');
}

return type;
}

private initPropertyType(meta: EntityMetadata, prop: EntityProperty): void {
const { type, optional } = this.readTypeFromSource(meta, prop);
const { type: typeRaw, optional } = this.readTypeFromSource(meta, prop);
const type = this.cleanUpTypeTags(typeRaw);
prop.type = type;
prop.runtimeType = type as 'string';

Expand Down
Loading

0 comments on commit 016fe63

Please sign in to comment.