Skip to content

Commit

Permalink
feat(orm): new API to configure a join query
Browse files Browse the repository at this point in the history
An alternative to `useXY().xy.end()` API, now with a callback system.

```
database.query(Basket)
   .joinWith('items', join => join.joinWith('product'))
   .find();
```
  • Loading branch information
marcj committed Feb 2, 2024
1 parent 49731fd commit 64cc55e
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 49 deletions.
9 changes: 9 additions & 0 deletions packages/orm-integration/src/bookstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},

Expand Down
98 changes: 56 additions & 42 deletions packages/orm/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { EventToken } from '@deepkit/event';
export type SORT_ORDER = 'asc' | 'desc' | any;
export type Sort<T extends OrmEntity, ORDER extends SORT_ORDER = SORT_ORDER> = { [P in keyof T & string]?: ORDER };

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

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

m.joins = this.joins.map((v) => {
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(),
};
});

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

export type Configure<T extends OrmEntity> = (query: BaseQuery<T>) => BaseQuery<T> | void;

export class BaseQuery<T extends OrmEntity> {
//for higher kinded type for selected fields
_!: () => T;
Expand All @@ -235,11 +233,11 @@ export class BaseQuery<T extends OrmEntity> {
*
* 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<Product>) {
* function joinFrontendData(query: BaseQuery<Product>) {
* return query
* .useJoinWith('images').select('sort').end()
* .useJoinWith('brand').select('id', 'name', 'website').end()
Expand All @@ -249,7 +247,8 @@ export class BaseQuery<T extends OrmEntity> {
* ```
* @reflection never
*/
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>> {
use<Q, R, A extends any[]>(modifier: (query: Q, ...args: A) => R, ...args: A) : this
{
return modifier(this as any, ...args) as any;
}

Expand Down Expand Up @@ -536,23 +535,38 @@ export class BaseQuery<T extends OrmEntity> {
* 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<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, type: 'left' | 'inner' = 'left', populate: boolean = false): this {
join<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(
field: K, type: 'left' | 'inner' = 'left', populate: boolean = false,
configure?: Configure<ENTITY>
): 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<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(
field: K, type: 'left' | 'inner' = 'left', populate: boolean = false,
configure?: Configure<ENTITY>
): [thisQuery: this, joinQuery: BaseQuery<ENTITY>] {
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`);
}
const c = this.clone();

const foreignReflectionClass = resolveForeignReflectionClass(propertySchema);
const query = new JoinDatabaseQuery<ENTITY, this>(foreignReflectionClass, c, field as string);
let query = new BaseQuery<ENTITY>(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];
}

/**
Expand All @@ -561,65 +575,65 @@ export class BaseQuery<T extends OrmEntity> {
* Returns JoinDatabaseQuery to further specify the join, which you need to `.end()`
*/
useJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
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<K extends keyof ReferenceFields<T>>(field: K): this {
return this.join(field, 'left', true);
joinWith<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, configure?: Configure<ENTITY>): this {
return this.addJoin(field, 'left', true, configure)[0];
}

/**
* Adds a left 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()`
*/
useJoinWith<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
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<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
getJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): BaseQuery<ENTITY> {
for (const join of this.model.joins) {
if (join.propertySchema.name === field) return join.query;
}
throw new Error(`No join fo reference ${String(field)} added.`);
}

/**
* 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<K extends keyof ReferenceFields<T>>(field: K): this {
return this.join(field, 'inner', true);
innerJoinWith<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, configure?: Configure<ENTITY>): 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<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
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<K extends keyof ReferenceFields<T>>(field: K): this {
return this.join(field, 'inner');
innerJoin<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K, configure?: Configure<ENTITY>): 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<K extends keyof ReferenceFields<T>, ENTITY extends OrmEntity = FindEntity<T[K]>>(field: K): JoinDatabaseQuery<ENTITY, this> {
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]);
}
}

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

export class JoinDatabaseQuery<T extends OrmEntity, PARENT extends BaseQuery<any>> extends BaseQuery<T> {
constructor(
public readonly foreignClassSchema: ReflectionClass<T>,
public parentQuery?: PARENT,
public field?: string,
// important to have this as first argument, since clone() uses it
classSchema: ReflectionClass<any>,
public query: BaseQuery<any>,
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<T extends OrmEntity> = JoinDatabaseQuery<T, any> | Query<T>;
export type AnyQuery<T extends OrmEntity> = BaseQuery<T>;
52 changes: 46 additions & 6 deletions packages/orm/tests/query.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -135,16 +135,16 @@ test('query lift', async () => {
return q.filterField('openBillings', { $gt: 0 });
}

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

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

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

class OverwriteHello<T extends OrmEntity> extends Query<T> {
Expand Down Expand Up @@ -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<IsExact<string[], typeof items>>(true);
}
Expand All @@ -261,6 +261,46 @@ test('query lift', async () => {
expect(items).toEqual(['foo', 'bar']);
assert<IsExact<string[], typeof items>>(true);
}

{
const items = await q.joinWith('image', filterImageSize).fetch(allUserNames);
expect(items).toEqual(['foo', 'bar']);
assert<IsExact<string[], typeof items>>(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);
}
});


Expand Down
2 changes: 1 addition & 1 deletion packages/sql/src/sql-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class Sql {

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

protected placeholderStrategy: SqlPlaceholderStrategy;

Expand Down
21 changes: 21 additions & 0 deletions packages/sqlite/tests/sqlite.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit 64cc55e

Please sign in to comment.