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

fix(knex): allow using knex query builder as virtual entity expression #4740

Merged
merged 1 commit into from
Sep 24, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading