Skip to content

Commit 64cc55e

Browse files
committed
feat(orm): new API to configure a join query
An alternative to `useXY().xy.end()` API, now with a callback system. ``` database.query(Basket) .joinWith('items', join => join.joinWith('product')) .find(); ```
1 parent 49731fd commit 64cc55e

File tree

5 files changed

+133
-49
lines changed

5 files changed

+133
-49
lines changed

packages/orm-integration/src/bookstore.ts

+9
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,15 @@ export const bookstoreTests = {
588588
expect(userWrongPwButLeftJoin.id).toBe(1);
589589
expect(userWrongPwButLeftJoin.credentials).toBeUndefined();
590590
}
591+
592+
{
593+
const query = session.query(User)
594+
.filter({ name: 'peter' })
595+
.innerJoinWith('credentials', join => join.filter({ password: 'wrongPassword' }));
596+
expect(query.getJoin('credentials').model.filter).toEqual({ password: 'wrongPassword' });
597+
const userWrongPw = await query.findOneOrUndefined();
598+
expect(userWrongPw).toBeUndefined();
599+
}
591600
database.disconnect();
592601
},
593602

packages/orm/src/query.ts

+56-42
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { EventToken } from '@deepkit/event';
3434
export type SORT_ORDER = 'asc' | 'desc' | any;
3535
export type Sort<T extends OrmEntity, ORDER extends SORT_ORDER = SORT_ORDER> = { [P in keyof T & string]?: ORDER };
3636

37-
export interface DatabaseJoinModel<T extends OrmEntity, PARENT extends BaseQuery<any>> {
37+
export interface DatabaseJoinModel<T extends OrmEntity> {
3838
//this is the parent classSchema, the foreign classSchema is stored in `query`
3939
classSchema: ReflectionClass<T>,
4040
propertySchema: ReflectionProperty,
@@ -43,7 +43,7 @@ export interface DatabaseJoinModel<T extends OrmEntity, PARENT extends BaseQuery
4343
//defines the field name under which the database engine populated the results.
4444
//necessary for the formatter to pick it up, convert and set correctly the real field name
4545
as?: string,
46-
query: JoinDatabaseQuery<T, PARENT>,
46+
query: BaseQuery<T>,
4747
foreignPrimaryKey: ReflectionProperty,
4848
}
4949

@@ -94,7 +94,7 @@ export class DatabaseQueryModel<T extends OrmEntity, FILTER extends FilterQuery<
9494
public aggregate = new Map<string, { property: ReflectionProperty, func: string }>();
9595
public select: Set<string> = new Set<string>();
9696
public lazyLoad: Set<string> = new Set<string>();
97-
public joins: DatabaseJoinModel<any, any>[] = [];
97+
public joins: DatabaseJoinModel<any>[] = [];
9898
public skip?: number;
9999
public itemsPerPage: number = 50;
100100
public limit?: number;
@@ -151,12 +151,8 @@ export class DatabaseQueryModel<T extends OrmEntity, FILTER extends FilterQuery<
151151

152152
m.joins = this.joins.map((v) => {
153153
return {
154-
classSchema: v.classSchema,
155-
propertySchema: v.propertySchema,
156-
type: v.type,
157-
populate: v.populate,
158-
query: v.query.clone(parentQuery),
159-
foreignPrimaryKey: v.foreignPrimaryKey,
154+
...v,
155+
query: v.query.clone(),
160156
};
161157
});
162158

@@ -213,6 +209,8 @@ export interface QueryClassType<T> {
213209
create(query: BaseQuery<any>): QueryClassType<T>;
214210
}
215211

212+
export type Configure<T extends OrmEntity> = (query: BaseQuery<T>) => BaseQuery<T> | void;
213+
216214
export class BaseQuery<T extends OrmEntity> {
217215
//for higher kinded type for selected fields
218216
_!: () => T;
@@ -235,11 +233,11 @@ export class BaseQuery<T extends OrmEntity> {
235233
*
236234
* This allows to use more dynamic query composition functions.
237235
*
238-
* To support joins queries `AnyQuery` is necessary as query type.
236+
* To support joins queries `BaseQuery` is necessary as query type.
239237
*
240238
* @example
241239
* ```typescript
242-
* function joinFrontendData(query: AnyQuery<Product>) {
240+
* function joinFrontendData(query: BaseQuery<Product>) {
243241
* return query
244242
* .useJoinWith('images').select('sort').end()
245243
* .useJoinWith('brand').select('id', 'name', 'website').end()
@@ -249,7 +247,8 @@ export class BaseQuery<T extends OrmEntity> {
249247
* ```
250248
* @reflection never
251249
*/
252-
use<Q, R, A extends any[]>(modifier: (query: Q, ...args: A) => R, ...args: A): this extends JoinDatabaseQuery<any, any> ? this : Exclude<R, JoinDatabaseQuery<any, any>> {
250+
use<Q, R, A extends any[]>(modifier: (query: Q, ...args: A) => R, ...args: A) : this
251+
{
253252
return modifier(this as any, ...args) as any;
254253
}
255254

@@ -536,23 +535,38 @@ export class BaseQuery<T extends OrmEntity> {
536535
* Adds a left join in the filter. Does NOT populate the reference with values.
537536
* Accessing `field` in the entity (if not optional field) results in an error.
538537
*/
539-
join<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, type: 'left' | 'inner' = 'left', populate: boolean = false): this {
538+
join<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(
539+
field: K, type: 'left' | 'inner' = 'left', populate: boolean = false,
540+
configure?: Configure<ENTITY>
541+
): this {
542+
return this.addJoin(field, type, populate, configure)[0];
543+
}
544+
545+
/**
546+
* Adds a left join in the filter and returns new this query and the join query.
547+
*/
548+
protected addJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(
549+
field: K, type: 'left' | 'inner' = 'left', populate: boolean = false,
550+
configure?: Configure<ENTITY>
551+
): [thisQuery: this, joinQuery: BaseQuery<ENTITY>] {
540552
const propertySchema = this.classSchema.getProperty(field as string);
541553
if (!propertySchema.isReference() && !propertySchema.isBackReference()) {
542554
throw new Error(`Field ${String(field)} is not marked as reference. Use Reference type`);
543555
}
544556
const c = this.clone();
545557

546558
const foreignReflectionClass = resolveForeignReflectionClass(propertySchema);
547-
const query = new JoinDatabaseQuery<ENTITY, this>(foreignReflectionClass, c, field as string);
559+
let query = new BaseQuery<ENTITY>(foreignReflectionClass);
548560
query.model.parameters = c.model.parameters;
561+
if (configure) query = configure(query) || query;
549562

550563
c.model.joins.push({
551564
propertySchema, query, populate, type,
552565
foreignPrimaryKey: foreignReflectionClass.getPrimary(),
553566
classSchema: this.classSchema,
554567
});
555-
return c;
568+
569+
return [c, query];
556570
}
557571

558572
/**
@@ -561,65 +575,65 @@ export class BaseQuery<T extends OrmEntity> {
561575
* Returns JoinDatabaseQuery to further specify the join, which you need to `.end()`
562576
*/
563577
useJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
564-
const c = this.join(field, 'left');
565-
return c.model.joins[c.model.joins.length - 1].query;
578+
const c = this.addJoin(field, 'left');
579+
return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]);
566580
}
567581

568582
/**
569583
* Adds a left join in the filter and populates the result set WITH reference field accordingly.
570584
*/
571-
joinWith<K extends keyof ReferenceFields<T>>(field: K): this {
572-
return this.join(field, 'left', true);
585+
joinWith<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, configure?: Configure<ENTITY>): this {
586+
return this.addJoin(field, 'left', true, configure)[0];
573587
}
574588

575589
/**
576590
* Adds a left join in the filter and populates the result set WITH reference field accordingly.
577591
* Returns JoinDatabaseQuery to further specify the join, which you need to `.end()`
578592
*/
579593
useJoinWith<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
580-
const c = this.join(field, 'left', true);
581-
return c.model.joins[c.model.joins.length - 1].query;
594+
const c = this.addJoin(field, 'left', true);
595+
return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]);
582596
}
583597

584-
getJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
598+
getJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): BaseQuery<ENTITY> {
585599
for (const join of this.model.joins) {
586600
if (join.propertySchema.name === field) return join.query;
587601
}
588602
throw new Error(`No join fo reference ${String(field)} added.`);
589603
}
590604

591605
/**
592-
* Adds a inner join in the filter and populates the result set WITH reference field accordingly.
606+
* Adds an inner join in the filter and populates the result set WITH reference field accordingly.
593607
*/
594-
innerJoinWith<K extends keyof ReferenceFields<T>>(field: K): this {
595-
return this.join(field, 'inner', true);
608+
innerJoinWith<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, configure?: Configure<ENTITY>): this {
609+
return this.addJoin(field, 'inner', true, configure)[0];
596610
}
597611

598612
/**
599-
* Adds a inner join in the filter and populates the result set WITH reference field accordingly.
613+
* Adds an inner join in the filter and populates the result set WITH reference field accordingly.
600614
* Returns JoinDatabaseQuery to further specify the join, which you need to `.end()`
601615
*/
602616
useInnerJoinWith<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
603-
const c = this.join(field, 'inner', true);
604-
return c.model.joins[c.model.joins.length - 1].query;
617+
const c = this.addJoin(field, 'inner', true);
618+
return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]);
605619
}
606620

607621
/**
608-
* Adds a inner join in the filter. Does NOT populate the reference with values.
622+
* Adds an inner join in the filter. Does NOT populate the reference with values.
609623
* Accessing `field` in the entity (if not optional field) results in an error.
610624
*/
611-
innerJoin<K extends keyof ReferenceFields<T>>(field: K): this {
612-
return this.join(field, 'inner');
625+
innerJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, configure?: Configure<ENTITY>): this {
626+
return this.addJoin(field, 'inner', false, configure)[0];
613627
}
614628

615629
/**
616-
* Adds a inner join in the filter. Does NOT populate the reference with values.
630+
* Adds an inner join in the filter. Does NOT populate the reference with values.
617631
* Accessing `field` in the entity (if not optional field) results in an error.
618632
* Returns JoinDatabaseQuery to further specify the join, which you need to `.end()`
619633
*/
620634
useInnerJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
621-
const c = this.join(field, 'inner');
622-
return c.model.joins[c.model.joins.length - 1].query;
635+
const c = this.addJoin(field, 'inner');
636+
return new JoinDatabaseQuery(c[1].classSchema, c[1], c[0]);
623637
}
624638
}
625639

@@ -1002,27 +1016,27 @@ export class Query<T extends OrmEntity> extends BaseQuery<T> {
10021016

10031017
export class JoinDatabaseQuery<T extends OrmEntity, PARENT extends BaseQuery<any>> extends BaseQuery<T> {
10041018
constructor(
1005-
public readonly foreignClassSchema: ReflectionClass<T>,
1006-
public parentQuery?: PARENT,
1007-
public field?: string,
1019+
// important to have this as first argument, since clone() uses it
1020+
classSchema: ReflectionClass<any>,
1021+
public query: BaseQuery<any>,
1022+
public parentQuery?: PARENT
10081023
) {
1009-
super(foreignClassSchema);
1024+
super(classSchema);
10101025
}
10111026

10121027
clone(parentQuery?: PARENT): this {
10131028
const c = super.clone();
10141029
c.parentQuery = parentQuery || this.parentQuery;
1015-
c.field = this.field;
1030+
c.query = this.query;
10161031
return c;
10171032
}
10181033

10191034
end(): PARENT {
10201035
if (!this.parentQuery) throw new Error('Join has no parent query');
1021-
if (!this.field) throw new Error('Join has no field');
10221036
//the parentQuery has not the updated JoinDatabaseQuery stuff, we need to move it now to there
1023-
this.parentQuery.getJoin(this.field).model = this.model;
1037+
this.query.model = this.model;
10241038
return this.parentQuery;
10251039
}
10261040
}
10271041

1028-
export type AnyQuery<T extends OrmEntity> = JoinDatabaseQuery<T, any> | Query<T>;
1042+
export type AnyQuery<T extends OrmEntity> = BaseQuery<T>;

packages/orm/tests/query.spec.ts

+46-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { BackReference, deserialize, Index, PrimaryKey, Reference, UUID, uuid } from '@deepkit/type';
1+
import { AutoIncrement, BackReference, deserialize, Index, PrimaryKey, Reference, UUID, uuid } from '@deepkit/type';
22
import { expect, test } from '@jest/globals';
33
import { assert, IsExact } from 'conditional-type-checks';
44
import { Database } from '../src/database.js';
55
import { MemoryDatabaseAdapter, MemoryQuery } from '../src/memory-db.js';
6-
import { AnyQuery, Query } from '../src/query.js';
6+
import { AnyQuery, BaseQuery, Query } from '../src/query.js';
77
import { OrmEntity } from '../src/type.js';
88

99
test('types do not interfere with type check', () => {
@@ -135,16 +135,16 @@ test('query lift', async () => {
135135
return q.filterField('openBillings', { $gt: 0 });
136136
}
137137

138-
function filterMinBilling(q: AnyQuery<User>, min: number) {
138+
function filterMinBilling(q: BaseQuery<User>, min: number) {
139139
return q.filterField('openBillings', { $gt: min });
140140
}
141141

142142
function allUserNames(q: Query<User>) {
143143
return q.findField('username');
144144
}
145145

146-
function filterImageSize(q: AnyQuery<UserImage>) {
147-
return q.filterField('size', { $gt: 0 });
146+
function filterImageSize(q: BaseQuery<UserImage>) {
147+
return q.filterField('size', { $gt: 5 });
148148
}
149149

150150
class OverwriteHello<T extends OrmEntity> extends Query<T> {
@@ -245,7 +245,7 @@ test('query lift', async () => {
245245
}
246246

247247
{
248-
const items = await q.use(filterBillingDue).use(allUserNames);
248+
const items = await allUserNames(q.use(filterBillingDue));
249249
expect(items).toEqual(['bar']);
250250
assert<IsExact<string[], typeof items>>(true);
251251
}
@@ -261,6 +261,46 @@ test('query lift', async () => {
261261
expect(items).toEqual(['foo', 'bar']);
262262
assert<IsExact<string[], typeof items>>(true);
263263
}
264+
265+
{
266+
const items = await q.joinWith('image', filterImageSize).fetch(allUserNames);
267+
expect(items).toEqual(['foo', 'bar']);
268+
assert<IsExact<string[], typeof items>>(true);
269+
}
270+
});
271+
272+
test('join with maintains model', () => {
273+
class Flat {
274+
public id: number & PrimaryKey & AutoIncrement = 0;
275+
}
276+
277+
class Tenant {
278+
public id: number & PrimaryKey & AutoIncrement = 0;
279+
name!: string;
280+
}
281+
282+
class Property {
283+
id!: number & PrimaryKey;
284+
flats: Flat[] & BackReference = [];
285+
tenants: Tenant[] & BackReference = [];
286+
}
287+
288+
const database = new Database(new MemoryDatabaseAdapter());
289+
{
290+
const query = database.query(Property)
291+
.joinWith('flats').joinWith('tenants');
292+
293+
expect(query.model.joins[0].populate).toBe(true);
294+
expect(query.model.joins[1].populate).toBe(true);
295+
}
296+
297+
{
298+
const query = database.query(Property)
299+
.joinWith('flats').useJoinWith('tenants').sort({ name: 'desc' }).end();
300+
301+
expect(query.model.joins[0].populate).toBe(true);
302+
expect(query.model.joins[1].populate).toBe(true);
303+
}
264304
});
265305

266306

packages/sql/src/sql-builder.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class Sql {
3737

3838
export class SqlBuilder {
3939
protected sqlSelect: string[] = [];
40-
protected joins: { join: DatabaseJoinModel<any, any>, forJoinIndex: number, startIndex: number, converter: ConvertDataToDict }[] = [];
40+
protected joins: { join: DatabaseJoinModel<any>, forJoinIndex: number, startIndex: number, converter: ConvertDataToDict }[] = [];
4141

4242
protected placeholderStrategy: SqlPlaceholderStrategy;
4343

packages/sqlite/tests/sqlite.spec.ts

+21
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,16 @@ test('multiple joins', async () => {
615615
expect(list[0].tenants).toMatchObject([{ name: 'tenant2' }, { name: 'tenant1' }]);
616616
}
617617

618+
{
619+
const list = await database.query(Property)
620+
.joinWith('flats')
621+
.joinWith('tenants', v => v.sort({ name: 'desc' }))
622+
.find();
623+
expect(list).toHaveLength(1);
624+
expect(list[0].flats).toMatchObject([{ name: 'flat1' }, { name: 'flat2' }]);
625+
expect(list[0].tenants).toMatchObject([{ name: 'tenant2' }, { name: 'tenant1' }]);
626+
}
627+
618628
const property2 = new Property('immo2');
619629
property2.flats.push(new Flat(property2, 'flat3'));
620630
property2.flats.push(new Flat(property2, 'flat4'));
@@ -756,6 +766,17 @@ test('deep join population', async () => {
756766
expect(basket.items[0].product).toBeInstanceOf(Product);
757767
expect(basket.items[1].product).toBeInstanceOf(Product);
758768
}
769+
770+
{
771+
const basket = await database.query(Basket)
772+
.joinWith('items', v=> v.joinWith('product'))
773+
.findOne();
774+
expect(basket).toBeInstanceOf(Basket);
775+
expect(basket.items[0]).toBeInstanceOf(BasketItem);
776+
expect(basket.items[1]).toBeInstanceOf(BasketItem);
777+
expect(basket.items[0].product).toBeInstanceOf(Product);
778+
expect(basket.items[1].product).toBeInstanceOf(Product);
779+
}
759780
});
760781

761782
test('joinWith', async () => {

0 commit comments

Comments
 (0)