Skip to content

Commit

Permalink
feat(query-builder): support virtual entities
Browse files Browse the repository at this point in the history
Closes #5069
  • Loading branch information
B4nan committed Jan 4, 2024
1 parent e75470d commit 27f0c83
Show file tree
Hide file tree
Showing 3 changed files with 68 additions and 33 deletions.
2 changes: 1 addition & 1 deletion packages/knex/src/AbstractSqlDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export abstract class AbstractSqlDriver<Connection extends AbstractSqlConnection

qb.where(where);

const kqb = qb.getKnexQuery().clear('select');
const kqb = qb.getKnexQuery(false).clear('select');

if (type === QueryType.COUNT) {
kqb.select(this.connection.getKnex().raw('count(*) as count'));
Expand Down
76 changes: 53 additions & 23 deletions packages/knex/src/query/QueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ export class QueryBuilder<T extends object = AnyEntity> {
return this.where(cond as string, params, '$or');
}

orderBy(orderBy: QBQueryOrderMap<T> | QBQueryOrderMap<T>[]): this {
orderBy(orderBy: QBQueryOrderMap<T> | QBQueryOrderMap<T>[]): SelectQueryBuilder<T> {
this.ensureNotFinalized();
this._orderBy = [];
Utils.asArray<QBQueryOrderMap<T>>(orderBy).forEach(o => {
Expand All @@ -404,16 +404,17 @@ export class QueryBuilder<T extends object = AnyEntity> {
this._orderBy.push(CriteriaNodeFactory.createNode<T>(this.metadata, this.mainAlias.entityName, processed).process(this, { matchPopulateJoins: true }));
});

return this;
return this as SelectQueryBuilder<T>;
}

groupBy(fields: (string | keyof T) | readonly (string | keyof T)[]): this {
groupBy(fields: (string | keyof T) | readonly (string | keyof T)[]): SelectQueryBuilder<T> {
this.ensureNotFinalized();
this._groupBy = Utils.asArray(fields);
return this;

return this as SelectQueryBuilder<T>;
}

having(cond: QBFilterQuery | string = {}, params?: any[]): this {
having(cond: QBFilterQuery | string = {}, params?: any[]): SelectQueryBuilder<T> {
this.ensureNotFinalized();

if (Utils.isString(cond)) {
Expand All @@ -422,10 +423,10 @@ export class QueryBuilder<T extends object = AnyEntity> {

this._having = CriteriaNodeFactory.createNode<T>(this.metadata, this.mainAlias.entityName, cond).process(this);

return this;
return this as SelectQueryBuilder<T>;
}

onConflict(fields: Field<T> | Field<T>[] = []): this {
onConflict(fields: Field<T> | Field<T>[] = []): InsertQueryBuilder<T> {
const meta = this.mainAlias.metadata as EntityMetadata<T>;
this.ensureNotFinalized();
this._onConflict ??= [];
Expand All @@ -436,7 +437,7 @@ export class QueryBuilder<T extends object = AnyEntity> {
return meta.properties[key]?.fieldNames ?? [key];
}),
});
return this;
return this as InsertQueryBuilder<T>;
}

ignore(): this {
Expand Down Expand Up @@ -477,21 +478,21 @@ export class QueryBuilder<T extends object = AnyEntity> {
return this;
}

limit(limit?: number, offset = 0): this {
limit(limit?: number, offset = 0): SelectQueryBuilder<T> {
this.ensureNotFinalized();
this._limit = limit;

if (offset) {
this.offset(offset);
}

return this;
return this as SelectQueryBuilder<T>;
}

offset(offset?: number): this {
offset(offset?: number): SelectQueryBuilder<T> {
this.ensureNotFinalized();
this._offset = offset;
return this;
return this as SelectQueryBuilder<T>;
}

withSchema(schema?: string): this {
Expand Down Expand Up @@ -575,9 +576,9 @@ export class QueryBuilder<T extends object = AnyEntity> {
* Specifies FROM which entity's table select/update/delete will be executed, removing all previously set FROM-s.
* Allows setting a main string alias of the selection data.
*/
from<T extends AnyEntity<T> = AnyEntity>(target: QueryBuilder<T>, aliasName?: string): QueryBuilder<T>;
from<T extends AnyEntity<T> = AnyEntity>(target: EntityName<T>): QueryBuilder<T>;
from<T extends AnyEntity<T> = AnyEntity>(target: EntityName<T> | QueryBuilder<T>, aliasName?: string): QueryBuilder<T> {
from<T extends AnyEntity<T> = AnyEntity>(target: QueryBuilder<T>, aliasName?: string): SelectQueryBuilder<T>;
from<T extends AnyEntity<T> = AnyEntity>(target: EntityName<T>): SelectQueryBuilder<T>;
from<T extends AnyEntity<T> = AnyEntity>(target: EntityName<T> | QueryBuilder<T>, aliasName?: string): SelectQueryBuilder<T> {
this.ensureNotFinalized();

if (target instanceof QueryBuilder) {
Expand All @@ -592,12 +593,12 @@ export class QueryBuilder<T extends object = AnyEntity> {
this.fromEntityName(entityName, aliasName);
}

return this as unknown as QueryBuilder<T>;
return this as unknown as SelectQueryBuilder<T>;
}

getKnexQuery(): Knex.QueryBuilder {
getKnexQuery(processVirtualEntity = true): Knex.QueryBuilder {
this.finalize();
const qb = this.getQueryBase();
const qb = this.getQueryBase(processVirtualEntity);
const type = this.type ?? QueryType.SELECT;
(qb as Dictionary).__raw = true; // tag it as there is now way to check via `instanceof`

Expand Down Expand Up @@ -940,9 +941,9 @@ export class QueryBuilder<T extends object = AnyEntity> {
return qb;
}

getKnex(): Knex.QueryBuilder {
getKnex(processVirtualEntity = true): Knex.QueryBuilder {
const qb = this.knex.queryBuilder();
const { subQuery, aliasName, entityName } = this.mainAlias;
const { subQuery, aliasName, entityName, metadata } = this.mainAlias;
const ref = subQuery ? subQuery : this.knex.ref(this.helper.getTableName(entityName));

if (this.finalized && (this._explicitAlias || this.helper.isTableNameAliasRequired(this.type))) {
Expand All @@ -955,7 +956,11 @@ export class QueryBuilder<T extends object = AnyEntity> {
ref.withSchema(schema);
}

qb.from(ref);
if (metadata?.virtual && processVirtualEntity) {
qb.fromRaw(this.fromVirtual(metadata));
} else {
qb.from(ref);
}

if (this.context) {
qb.transacting(this.context);
Expand All @@ -964,6 +969,31 @@ export class QueryBuilder<T extends object = AnyEntity> {
return qb;
}

private fromVirtual<T extends object>(meta: EntityMetadata<T>): string {
if (typeof meta.expression === 'string') {
return `(${meta.expression}) as ${this.platform.quoteIdentifier(this.alias)}`;
}

const res = meta.expression!(this.em, this._cond as any, {});

if (typeof res === 'string') {
return `(${res}) as ${this.platform.quoteIdentifier(this.alias)}`;
}

if (res instanceof QueryBuilder) {
return `(${res.getFormattedQuery()}) as ${this.platform.quoteIdentifier(this.alias)}`;
}

if (Utils.isObject<Knex.QueryBuilder | Knex.Raw>(res)) {
const { sql, bindings } = res.toSQL();
const query = this.platform.formatQuery(sql, bindings);
return `(${query}) as ${this.platform.quoteIdentifier(this.alias)}`;
}

/* istanbul ignore next */
return res as unknown as string;
}

private joinReference(field: string | Knex.QueryBuilder | QueryBuilder, alias: string, cond: Dictionary, type: JoinType, path?: string, schema?: string, subquery?: string): EntityProperty<T> {
this.ensureNotFinalized();

Expand Down Expand Up @@ -1168,8 +1198,8 @@ export class QueryBuilder<T extends object = AnyEntity> {
return this;
}

private getQueryBase(): Knex.QueryBuilder {
const qb = this.getKnex();
private getQueryBase(processVirtualEntity: boolean): Knex.QueryBuilder {
const qb = this.getKnex(processVirtualEntity);
const schema = this.getSchema(this.mainAlias);
// Joined tables doesn't need to belong to the same schema as the main table
const joinSchema = this._schema ?? this.em?.schema ?? schema;
Expand Down
23 changes: 14 additions & 9 deletions tests/features/virtual-entities/virtual-entities.sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EntitySchema, MikroORM, QueryFlag, ReferenceKind, raw, sql } from '@mikro-orm/core';
import type { EntityManager } from '@mikro-orm/better-sqlite';
import { EntitySchema, QueryFlag, ReferenceKind, raw, sql } from '@mikro-orm/core';
import { EntityManager, MikroORM } from '@mikro-orm/better-sqlite';
import { mockLogger } from '../../bootstrap';
import type { IAuthor4 } from '../../entities-schema';
import { Author4, BaseEntity5, Book4, BookTag4, FooBar4, FooBaz4, Publisher4, Test4, Identity, IdentitySchema } from '../../entities-schema';
Expand All @@ -25,7 +25,7 @@ const AuthorProfileSchema = new EntitySchema({
expression: authorProfilesSQL,
properties: {
name: { type: 'string' },
age: { type: 'string' },
age: { type: 'number' },
totalBooks: { type: 'number' },
usedTags: { type: 'string[]' },
identity: { type: 'Identity', kind: ReferenceKind.EMBEDDED, object: true },
Expand All @@ -47,7 +47,7 @@ const AuthorProfileSchema2 = new EntitySchema({
expression: () => authorProfilesSQL,
properties: {
name: { type: 'string' },
age: { type: 'string' },
age: { type: 'number' },
totalBooks: { type: 'number' },
usedTags: { type: 'string[]' },
identity: { type: 'Identity', kind: ReferenceKind.EMBEDDED, object: true },
Expand Down Expand Up @@ -180,6 +180,10 @@ describe('virtual entities (sqlite)', () => {
expect(profile.identity).toBeInstanceOf(Identity);
}

const someProfiles0 = await orm.em.qb(AuthorProfile2).limit(2).offset(1).orderBy({ name: 'asc' });
expect(someProfiles0).toHaveLength(2);
expect(someProfiles0.map(p => p.name)).toEqual(['Jon Snow 2', 'Jon Snow 3']);

const someProfiles1 = await orm.em.find(AuthorProfile2, {}, { limit: 2, offset: 1, orderBy: { name: 'asc' } });
expect(someProfiles1).toHaveLength(2);
expect(someProfiles1.map(p => p.name)).toEqual(['Jon Snow 2', 'Jon Snow 3']);
Expand All @@ -196,13 +200,14 @@ describe('virtual entities (sqlite)', () => {
expect(someProfiles4).toHaveLength(2);
expect(someProfiles4.map(p => p.name)).toEqual(['Jon Snow 2', 'Jon Snow 3']);

expect(mock.mock.calls).toHaveLength(6);
expect(mock.mock.calls).toHaveLength(7);
expect(mock.mock.calls[0][0]).toMatch(`select count(*) as count from (${authorProfilesSQL}) as \`a0\``);
expect(mock.mock.calls[1][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\``);
expect(mock.mock.calls[2][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` order by \`a0\`.\`name\` asc limit 2 offset 1`);
expect(mock.mock.calls[3][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` order by \`a0\`.\`name\` asc limit 2`);
expect(mock.mock.calls[4][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` where \`a0\`.\`name\` like 'Jon%' and \`a0\`.\`age\` >= 0 order by \`a0\`.\`name\` asc limit 2`);
expect(mock.mock.calls[5][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` where \`a0\`.\`name\` in ('Jon Snow 2', 'Jon Snow 3')`);
expect(mock.mock.calls[2][0]).toMatch(`select \`a0\`.* from (${authorProfilesSQL}) as \`a0\` order by \`a0\`.\`name\` asc limit 2 offset 1`);
expect(mock.mock.calls[3][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` order by \`a0\`.\`name\` asc limit 2 offset 1`);
expect(mock.mock.calls[4][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` order by \`a0\`.\`name\` asc limit 2`);
expect(mock.mock.calls[5][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` where \`a0\`.\`name\` like 'Jon%' and \`a0\`.\`age\` >= 0 order by \`a0\`.\`name\` asc limit 2`);
expect(mock.mock.calls[6][0]).toMatch(`select * from (${authorProfilesSQL}) as \`a0\` where \`a0\`.\`name\` in ('Jon Snow 2', 'Jon Snow 3')`);
expect(orm.em.getUnitOfWork().getIdentityMap().keys()).toHaveLength(0);
});

Expand Down

0 comments on commit 27f0c83

Please sign in to comment.