diff --git a/packages/orm-integration/src/bookstore.ts b/packages/orm-integration/src/bookstore.ts index 6dda7ea6a..e8b14a2cc 100644 --- a/packages/orm-integration/src/bookstore.ts +++ b/packages/orm-integration/src/bookstore.ts @@ -588,6 +588,15 @@ export const bookstoreTests = { expect(userWrongPwButLeftJoin.id).toBe(1); expect(userWrongPwButLeftJoin.credentials).toBeUndefined(); } + + { + const query = session.query(User) + .filter({ name: 'peter' }) + .innerJoinWith('credentials', join => join.filter({ password: 'wrongPassword' })); + expect(query.getJoin('credentials').model.filter).toEqual({ password: 'wrongPassword' }); + const userWrongPw = await query.findOneOrUndefined(); + expect(userWrongPw).toBeUndefined(); + } database.disconnect(); }, diff --git a/packages/orm/src/query.ts b/packages/orm/src/query.ts index 7aa1a2a6f..9ff5516f6 100644 --- a/packages/orm/src/query.ts +++ b/packages/orm/src/query.ts @@ -34,7 +34,7 @@ import { EventToken } from '@deepkit/event'; export type SORT_ORDER = 'asc' | 'desc' | any; export type Sort = { [P in keyof T & string]?: ORDER }; -export interface DatabaseJoinModel> { +export interface DatabaseJoinModel { //this is the parent classSchema, the foreign classSchema is stored in `query` classSchema: ReflectionClass, propertySchema: ReflectionProperty, @@ -43,7 +43,7 @@ export interface DatabaseJoinModel, + query: BaseQuery, foreignPrimaryKey: ReflectionProperty, } @@ -94,7 +94,7 @@ export class DatabaseQueryModel(); public select: Set = new Set(); public lazyLoad: Set = new Set(); - public joins: DatabaseJoinModel[] = []; + public joins: DatabaseJoinModel[] = []; public skip?: number; public itemsPerPage: number = 50; public limit?: number; @@ -151,12 +151,8 @@ export class DatabaseQueryModel { return { - classSchema: v.classSchema, - propertySchema: v.propertySchema, - type: v.type, - populate: v.populate, - query: v.query.clone(parentQuery), - foreignPrimaryKey: v.foreignPrimaryKey, + ...v, + query: v.query.clone(), }; }); @@ -213,6 +209,8 @@ export interface QueryClassType { create(query: BaseQuery): QueryClassType; } +export type Configure = (query: BaseQuery) => BaseQuery | void; + export class BaseQuery { //for higher kinded type for selected fields _!: () => T; @@ -235,11 +233,11 @@ export class BaseQuery { * * This allows to use more dynamic query composition functions. * - * To support joins queries `AnyQuery` is necessary as query type. + * To support joins queries `BaseQuery` is necessary as query type. * * @example * ```typescript - * function joinFrontendData(query: AnyQuery) { + * function joinFrontendData(query: BaseQuery) { * return query * .useJoinWith('images').select('sort').end() * .useJoinWith('brand').select('id', 'name', 'website').end() @@ -249,7 +247,8 @@ export class BaseQuery { * ``` * @reflection never */ - use(modifier: (query: Q, ...args: A) => R, ...args: A): this extends JoinDatabaseQuery ? this : Exclude> { + use(modifier: (query: Q, ...args: A) => R, ...args: A) : this + { return modifier(this as any, ...args) as any; } @@ -536,7 +535,20 @@ export class BaseQuery { * Adds a left join in the filter. Does NOT populate the reference with values. * Accessing `field` in the entity (if not optional field) results in an error. */ - join, ENTITY extends OrmEntity = FindEntity>(field: K, type: 'left' | 'inner' = 'left', populate: boolean = false): this { + join, ENTITY extends OrmEntity = FindEntity>( + field: K, type: 'left' | 'inner' = 'left', populate: boolean = false, + configure?: Configure + ): this { + return this.addJoin(field, type, populate, configure)[0]; + } + + /** + * Adds a left join in the filter and returns new this query and the join query. + */ + protected addJoin, ENTITY extends OrmEntity = FindEntity>( + field: K, type: 'left' | 'inner' = 'left', populate: boolean = false, + configure?: Configure + ): [thisQuery: this, joinQuery: BaseQuery] { const propertySchema = this.classSchema.getProperty(field as string); if (!propertySchema.isReference() && !propertySchema.isBackReference()) { throw new Error(`Field ${String(field)} is not marked as reference. Use Reference type`); @@ -544,15 +556,17 @@ export class BaseQuery { const c = this.clone(); const foreignReflectionClass = resolveForeignReflectionClass(propertySchema); - const query = new JoinDatabaseQuery(foreignReflectionClass, c, field as string); + let query = new BaseQuery(foreignReflectionClass); query.model.parameters = c.model.parameters; + if (configure) query = configure(query) || query; c.model.joins.push({ propertySchema, query, populate, type, foreignPrimaryKey: foreignReflectionClass.getPrimary(), classSchema: this.classSchema, }); - return c; + + return [c, query]; } /** @@ -561,15 +575,15 @@ export class BaseQuery { * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` */ useJoin, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.join(field, 'left'); - return c.model.joins[c.model.joins.length - 1].query; + const c = this.addJoin(field, 'left'); + return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); } /** * Adds a left join in the filter and populates the result set WITH reference field accordingly. */ - joinWith>(field: K): this { - return this.join(field, 'left', true); + joinWith, ENTITY extends OrmEntity = FindEntity>(field: K, configure?: Configure): this { + return this.addJoin(field, 'left', true, configure)[0]; } /** @@ -577,11 +591,11 @@ export class BaseQuery { * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` */ useJoinWith, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.join(field, 'left', true); - return c.model.joins[c.model.joins.length - 1].query; + const c = this.addJoin(field, 'left', true); + return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); } - getJoin, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { + getJoin, ENTITY extends OrmEntity = FindEntity>(field: K): BaseQuery { for (const join of this.model.joins) { if (join.propertySchema.name === field) return join.query; } @@ -589,37 +603,37 @@ export class BaseQuery { } /** - * Adds a inner join in the filter and populates the result set WITH reference field accordingly. + * Adds an inner join in the filter and populates the result set WITH reference field accordingly. */ - innerJoinWith>(field: K): this { - return this.join(field, 'inner', true); + innerJoinWith, ENTITY extends OrmEntity = FindEntity>(field: K, configure?: Configure): this { + return this.addJoin(field, 'inner', true, configure)[0]; } /** - * Adds a inner join in the filter and populates the result set WITH reference field accordingly. + * Adds an inner join in the filter and populates the result set WITH reference field accordingly. * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` */ useInnerJoinWith, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.join(field, 'inner', true); - return c.model.joins[c.model.joins.length - 1].query; + const c = this.addJoin(field, 'inner', true); + return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); } /** - * Adds a inner join in the filter. Does NOT populate the reference with values. + * Adds an inner join in the filter. Does NOT populate the reference with values. * Accessing `field` in the entity (if not optional field) results in an error. */ - innerJoin>(field: K): this { - return this.join(field, 'inner'); + innerJoin, ENTITY extends OrmEntity = FindEntity>(field: K, configure?: Configure): this { + return this.addJoin(field, 'inner', false, configure)[0]; } /** - * Adds a inner join in the filter. Does NOT populate the reference with values. + * Adds an inner join in the filter. Does NOT populate the reference with values. * Accessing `field` in the entity (if not optional field) results in an error. * Returns JoinDatabaseQuery to further specify the join, which you need to `.end()` */ useInnerJoin, ENTITY extends OrmEntity = FindEntity>(field: K): JoinDatabaseQuery { - const c = this.join(field, 'inner'); - return c.model.joins[c.model.joins.length - 1].query; + const c = this.addJoin(field, 'inner'); + return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]); } } @@ -1002,27 +1016,27 @@ export class Query extends BaseQuery { export class JoinDatabaseQuery> extends BaseQuery { constructor( - public readonly foreignClassSchema: ReflectionClass, - public parentQuery?: PARENT, - public field?: string, + // important to have this as first argument, since clone() uses it + classSchema: ReflectionClass, + public query: BaseQuery, + public parentQuery?: PARENT ) { - super(foreignClassSchema); + super(classSchema); } clone(parentQuery?: PARENT): this { const c = super.clone(); c.parentQuery = parentQuery || this.parentQuery; - c.field = this.field; + c.query = this.query; return c; } end(): PARENT { if (!this.parentQuery) throw new Error('Join has no parent query'); - if (!this.field) throw new Error('Join has no field'); //the parentQuery has not the updated JoinDatabaseQuery stuff, we need to move it now to there - this.parentQuery.getJoin(this.field).model = this.model; + this.query.model = this.model; return this.parentQuery; } } -export type AnyQuery = JoinDatabaseQuery | Query; +export type AnyQuery = BaseQuery; diff --git a/packages/orm/tests/query.spec.ts b/packages/orm/tests/query.spec.ts index 81400ddad..ee6d5c3c7 100644 --- a/packages/orm/tests/query.spec.ts +++ b/packages/orm/tests/query.spec.ts @@ -1,9 +1,9 @@ -import { BackReference, deserialize, Index, PrimaryKey, Reference, UUID, uuid } from '@deepkit/type'; +import { AutoIncrement, BackReference, deserialize, Index, PrimaryKey, Reference, UUID, uuid } from '@deepkit/type'; import { expect, test } from '@jest/globals'; import { assert, IsExact } from 'conditional-type-checks'; import { Database } from '../src/database.js'; import { MemoryDatabaseAdapter, MemoryQuery } from '../src/memory-db.js'; -import { AnyQuery, Query } from '../src/query.js'; +import { AnyQuery, BaseQuery, Query } from '../src/query.js'; import { OrmEntity } from '../src/type.js'; test('types do not interfere with type check', () => { @@ -135,7 +135,7 @@ test('query lift', async () => { return q.filterField('openBillings', { $gt: 0 }); } - function filterMinBilling(q: AnyQuery, min: number) { + function filterMinBilling(q: BaseQuery, min: number) { return q.filterField('openBillings', { $gt: min }); } @@ -143,8 +143,8 @@ test('query lift', async () => { return q.findField('username'); } - function filterImageSize(q: AnyQuery) { - return q.filterField('size', { $gt: 0 }); + function filterImageSize(q: BaseQuery) { + return q.filterField('size', { $gt: 5 }); } class OverwriteHello extends Query { @@ -245,7 +245,7 @@ test('query lift', async () => { } { - const items = await q.use(filterBillingDue).use(allUserNames); + const items = await allUserNames(q.use(filterBillingDue)); expect(items).toEqual(['bar']); assert>(true); } @@ -261,6 +261,46 @@ test('query lift', async () => { expect(items).toEqual(['foo', 'bar']); assert>(true); } + + { + const items = await q.joinWith('image', filterImageSize).fetch(allUserNames); + expect(items).toEqual(['foo', 'bar']); + assert>(true); + } +}); + +test('join with maintains model', () => { + class Flat { + public id: number & PrimaryKey & AutoIncrement = 0; + } + + class Tenant { + public id: number & PrimaryKey & AutoIncrement = 0; + name!: string; + } + + class Property { + id!: number & PrimaryKey; + flats: Flat[] & BackReference = []; + tenants: Tenant[] & BackReference = []; + } + + const database = new Database(new MemoryDatabaseAdapter()); + { + const query = database.query(Property) + .joinWith('flats').joinWith('tenants'); + + expect(query.model.joins[0].populate).toBe(true); + expect(query.model.joins[1].populate).toBe(true); + } + + { + const query = database.query(Property) + .joinWith('flats').useJoinWith('tenants').sort({ name: 'desc' }).end(); + + expect(query.model.joins[0].populate).toBe(true); + expect(query.model.joins[1].populate).toBe(true); + } }); diff --git a/packages/sql/src/sql-builder.ts b/packages/sql/src/sql-builder.ts index c371a2742..ad6eca465 100644 --- a/packages/sql/src/sql-builder.ts +++ b/packages/sql/src/sql-builder.ts @@ -37,7 +37,7 @@ export class Sql { export class SqlBuilder { protected sqlSelect: string[] = []; - protected joins: { join: DatabaseJoinModel, forJoinIndex: number, startIndex: number, converter: ConvertDataToDict }[] = []; + protected joins: { join: DatabaseJoinModel, forJoinIndex: number, startIndex: number, converter: ConvertDataToDict }[] = []; protected placeholderStrategy: SqlPlaceholderStrategy; diff --git a/packages/sqlite/tests/sqlite.spec.ts b/packages/sqlite/tests/sqlite.spec.ts index fb119768e..4c1ba5304 100644 --- a/packages/sqlite/tests/sqlite.spec.ts +++ b/packages/sqlite/tests/sqlite.spec.ts @@ -615,6 +615,16 @@ test('multiple joins', async () => { expect(list[0].tenants).toMatchObject([{ name: 'tenant2' }, { name: 'tenant1' }]); } + { + const list = await database.query(Property) + .joinWith('flats') + .joinWith('tenants', v => v.sort({ name: 'desc' })) + .find(); + expect(list).toHaveLength(1); + expect(list[0].flats).toMatchObject([{ name: 'flat1' }, { name: 'flat2' }]); + expect(list[0].tenants).toMatchObject([{ name: 'tenant2' }, { name: 'tenant1' }]); + } + const property2 = new Property('immo2'); property2.flats.push(new Flat(property2, 'flat3')); property2.flats.push(new Flat(property2, 'flat4')); @@ -756,6 +766,17 @@ test('deep join population', async () => { expect(basket.items[0].product).toBeInstanceOf(Product); expect(basket.items[1].product).toBeInstanceOf(Product); } + + { + const basket = await database.query(Basket) + .joinWith('items', v=> v.joinWith('product')) + .findOne(); + expect(basket).toBeInstanceOf(Basket); + expect(basket.items[0]).toBeInstanceOf(BasketItem); + expect(basket.items[1]).toBeInstanceOf(BasketItem); + expect(basket.items[0].product).toBeInstanceOf(Product); + expect(basket.items[1].product).toBeInstanceOf(Product); + } }); test('joinWith', async () => {