Skip to content

Commit

Permalink
fix(knex): allow using knex query builder as virtual entity expression (
Browse files Browse the repository at this point in the history
#4740)

Related: #4628
  • Loading branch information
B4nan committed Sep 24, 2023
1 parent 898dcda commit 427cc88
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 42 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/drivers/DatabaseDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export abstract class DatabaseDriver<C extends Connection> implements IDatabaseD
}

/* istanbul ignore next */
async countVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: CountOptions<T>): Promise<number> {
async countVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: CountOptions<T, any>): Promise<number> {
throw new Error(`Counting virtual entities is not supported by ${this.constructor.name} driver.`);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ export interface EntityMetadata<T = any> {
virtual?: boolean;
// we need to use `em: any` here otherwise an expression would not be assignable with more narrow type like `SqlEntityManager`
// also return type is unknown as it can be either QB instance (which we cannot type here) or array of POJOs (e.g. for mongodb)
expression?: string | ((em: any, where: FilterQuery<T>, options: FindOptions<T, any>) => object);
expression?: string | ((em: any, where: FilterQuery<T>, options: FindOptions<T, any>) => object | string);
discriminatorColumn?: string;
discriminatorValue?: number | string;
discriminatorMap?: Dictionary<string>;
Expand Down
53 changes: 22 additions & 31 deletions packages/knex/src/AbstractSqlDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,57 +139,48 @@ export abstract class AbstractSqlDriver<Connection extends AbstractSqlConnection
}

async findVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: FindOptions<T, any>): Promise<EntityData<T>[]> {
const meta = this.metadata.get<T>(entityName);

/* istanbul ignore next */
if (!meta.expression) {
return [];
}

if (typeof meta.expression === 'string') {
return this.wrapVirtualExpressionInSubquery(meta, meta.expression, where, options);
}

const em = this.createEntityManager(false);
em.setTransactionContext(options.ctx);
const res = meta.expression(em, where, options);

if (res instanceof QueryBuilder) {
return this.wrapVirtualExpressionInSubquery(meta, res.getFormattedQuery(), where, options);
}
return this.findFromVirtual(entityName, where, options, QueryType.SELECT) as Promise<EntityData<T>[]>;
}

/* istanbul ignore next */
return res as EntityData<T>[];
async countVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: CountOptions<T, any>): Promise<number> {
return this.findFromVirtual(entityName, where, options, QueryType.COUNT) as Promise<number>;
}

async countVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: CountOptions<T>): Promise<number> {
private async findFromVirtual<T extends object>(entityName: string, where: FilterQuery<T>, options: FindOptions<T, any> | CountOptions<T, any>, type: QueryType): Promise<EntityData<T>[] | number> {
const meta = this.metadata.get<T>(entityName);

/* istanbul ignore next */
if (!meta.expression) {
return 0;
return type === QueryType.SELECT ? [] : 0;
}

if (typeof meta.expression === 'string') {
return this.wrapVirtualExpressionInSubquery(meta, meta.expression, where, options as Dictionary, QueryType.COUNT);
return this.wrapVirtualExpressionInSubquery(meta, meta.expression, where, options as FindOptions<T, any>, type);
}

const em = this.createEntityManager(false);
em.setTransactionContext(options.ctx);
const res = meta.expression(em, where, options as Dictionary);
const res = meta.expression(em, where, options as FindOptions<T, any>);

if (typeof res === 'string') {
return this.wrapVirtualExpressionInSubquery(meta, res, where, options as FindOptions<T, any>, type);
}

if (res instanceof QueryBuilder) {
return this.wrapVirtualExpressionInSubquery(meta, res.getFormattedQuery(), where, options as Dictionary, QueryType.COUNT);
return this.wrapVirtualExpressionInSubquery(meta, res.getFormattedQuery(), where, options as FindOptions<T, any>, type);
}

if (Utils.isObject<Knex.QueryBuilder | Knex.Raw>(res)) {
const { sql, bindings } = res.toSQL();
const query = this.platform.formatQuery(sql, bindings);
return this.wrapVirtualExpressionInSubquery(meta, query, where, options as FindOptions<T, any>, type);
}

/* istanbul ignore next */
return res as any;
return res as EntityData<T>[];
}

protected async wrapVirtualExpressionInSubquery<T extends object>(meta: EntityMetadata<T>, expression: string, where: FilterQuery<T>, options: FindOptions<T, any>, type: QueryType.COUNT): Promise<number>;
protected async wrapVirtualExpressionInSubquery<T extends object>(meta: EntityMetadata<T>, expression: string, where: FilterQuery<T>, options: FindOptions<T, any>, type: QueryType.SELECT): Promise<T[]>;
protected async wrapVirtualExpressionInSubquery<T extends object>(meta: EntityMetadata<T>, expression: string, where: FilterQuery<T>, options: FindOptions<T, any>): Promise<T[]>;
protected async wrapVirtualExpressionInSubquery<T extends object>(meta: EntityMetadata<T>, expression: string, where: FilterQuery<T>, options: FindOptions<T, any>, type = QueryType.SELECT): Promise<unknown> {
protected async wrapVirtualExpressionInSubquery<T extends object>(meta: EntityMetadata<T>, expression: string, where: FilterQuery<T>, options: FindOptions<T, any>, type: QueryType): Promise<T[] | number> {
const qb = this.createQueryBuilder(meta.className, options?.ctx, options.connectionType, options.convertCustomTypes)
.limit(options?.limit, options?.offset)
.indexHint(options.indexHint!)
Expand Down Expand Up @@ -217,7 +208,7 @@ export abstract class AbstractSqlDriver<Connection extends AbstractSqlConnection
return (res[0] as Dictionary).count;
}

return res.map(row => this.mapResult(row, meta)!);
return res.map(row => this.mapResult(row, meta) as T);
}

mapResult<T extends object>(result: EntityData<T>, meta: EntityMetadata<T>, populate: PopulateOptions<T>[] = [], qb?: QueryBuilder<T>, map: Dictionary = {}): EntityData<T> | null {
Expand Down
57 changes: 48 additions & 9 deletions tests/features/virtual-entities/virtual-entities.sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ const AuthorProfileSchema = new EntitySchema({
},
});

class AuthorProfile2 {

name!: string;
age!: number;
totalBooks!: number;
usedTags!: string[];
identity!: Identity;

}

const AuthorProfileSchema2 = new EntitySchema({
class: AuthorProfile2,
expression: () => authorProfilesSQL,
properties: {
name: { type: 'string' },
age: { type: 'string' },
totalBooks: { type: 'number' },
usedTags: { type: 'string[]' },
identity: { type: 'Identity', reference: ReferenceType.EMBEDDED, object: true },
},
});

interface IBookWithAuthor{
title: string;
authorName: string;
Expand All @@ -54,6 +76,23 @@ const BookWithAuthor = new EntitySchema<IBookWithAuthor>({
},
});

const BookWithAuthor2 = new EntitySchema<IBookWithAuthor>({
name: 'BookWithAuthor2',
expression: (em: EntityManager) => {
return em.createQueryBuilder(Book4, 'b')
.select(['b.title', 'a.name as author_name', 'group_concat(t.name) as tags'])
.join('b.author', 'a')
.join('b.tags', 't')
.groupBy('b.id')
.getKnexQuery();
},
properties: {
title: { type: 'string' },
authorName: { type: 'string' },
tags: { type: 'string[]' },
},
});

describe('virtual entities (sqlite)', () => {

let orm: MikroORM;
Expand All @@ -62,7 +101,7 @@ describe('virtual entities (sqlite)', () => {
orm = await MikroORM.init({
driver: BetterSqliteDriver,
dbName: ':memory:',
entities: [Author4, Book4, BookTag4, Publisher4, Test4, FooBar4, FooBaz4, BaseEntity5, AuthorProfileSchema, BookWithAuthor, IdentitySchema],
entities: [Author4, Book4, BookTag4, Publisher4, Test4, FooBar4, FooBaz4, BaseEntity5, AuthorProfileSchema, BookWithAuthor, AuthorProfileSchema2, BookWithAuthor2, IdentitySchema],
});
await orm.schema.createSchema();
});
Expand Down Expand Up @@ -141,19 +180,19 @@ describe('virtual entities (sqlite)', () => {
expect(profile.identity).toBeInstanceOf(Identity);
}

const someProfiles1 = await orm.em.find(AuthorProfile, {}, { limit: 2, offset: 1, orderBy: { name: 'asc' } });
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']);

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

const someProfiles3 = await orm.em.find(AuthorProfile, { $and: [{ name: { $like: 'Jon%' } }, { age: { $gte: 0 } }] }, { limit: 2, orderBy: { name: 'asc' } });
const someProfiles3 = await orm.em.find(AuthorProfile2, { $and: [{ name: { $like: 'Jon%' } }, { age: { $gte: 0 } }] }, { limit: 2, orderBy: { name: 'asc' } });
expect(someProfiles3).toHaveLength(2);
expect(someProfiles3.map(p => p.name)).toEqual(['Jon Snow 1', 'Jon Snow 2']);

const someProfiles4 = await orm.em.find(AuthorProfile, { name: ['Jon Snow 2', 'Jon Snow 3'] });
const someProfiles4 = await orm.em.find(AuthorProfile2, { name: ['Jon Snow 2', 'Jon Snow 3'] });
expect(someProfiles4).toHaveLength(2);
expect(someProfiles4.map(p => p.name)).toEqual(['Jon Snow 2', 'Jon Snow 3']);

Expand Down Expand Up @@ -227,19 +266,19 @@ describe('virtual entities (sqlite)', () => {
expect(book.constructor.name).toBe('BookWithAuthor');
}

const someBooks1 = await orm.em.find(BookWithAuthor, {}, { limit: 2, offset: 1, orderBy: { title: 'asc' } });
const someBooks1 = await orm.em.find(BookWithAuthor2, {}, { limit: 2, offset: 1, orderBy: { title: 'asc' } });
expect(someBooks1).toHaveLength(2);
expect(someBooks1.map(p => p.title)).toEqual(['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3']);

const someBooks2 = await orm.em.find(BookWithAuthor, {}, { limit: 2, orderBy: { title: 'asc' } });
const someBooks2 = await orm.em.find(BookWithAuthor2, {}, { limit: 2, orderBy: { title: 'asc' } });
expect(someBooks2).toHaveLength(2);
expect(someBooks2.map(p => p.title)).toEqual(['My Life on the Wall, part 1/1', 'My Life on the Wall, part 1/2']);

const someBooks3 = await orm.em.find(BookWithAuthor, { $and: [{ title: { $like: 'My Life%' } }, { authorName: { $ne: null } }] }, { limit: 2, orderBy: { title: 'asc' } });
const someBooks3 = await orm.em.find(BookWithAuthor2, { $and: [{ title: { $like: 'My Life%' } }, { authorName: { $ne: null } }] }, { limit: 2, orderBy: { title: 'asc' } });
expect(someBooks3).toHaveLength(2);
expect(someBooks3.map(p => p.title)).toEqual(['My Life on the Wall, part 1/1', 'My Life on the Wall, part 1/2']);

const someBooks4 = await orm.em.find(BookWithAuthor, { title: ['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3'] });
const someBooks4 = await orm.em.find(BookWithAuthor2, { title: ['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3'] });
expect(someBooks4).toHaveLength(2);
expect(someBooks4.map(p => p.title)).toEqual(['My Life on the Wall, part 1/2', 'My Life on the Wall, part 1/3']);

Expand Down

0 comments on commit 427cc88

Please sign in to comment.