Skip to content

Commit

Permalink
Merge pull request #43 from ZaidMaslouhi/feature/add-join-conditions
Browse files Browse the repository at this point in the history
Enhanced Data Retrieval: Implement Join Conditions
  • Loading branch information
zaro authored Apr 9, 2024
2 parents b047951 + c167727 commit d1e1357
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 163 deletions.
33 changes: 29 additions & 4 deletions docs/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,23 @@ _Examples:_
**_Notice:_** primary field/column always persists in relational objects. To use nested relations, the parent level **MUST** be set before the child level like example above.

### New Feature: Join Condition (on clause)

The join parameter now supports specifying a WHERE condition within the ON clause of the join using the on property. This allows for more granular control over the joined data.

> ?join=**relation**||**field1**,**field2**,...||on[0]=**field**||**\$condition**||**value**,on[1]=**field**||**\$condition**...&join=...
_Examples:_

Suppose you want to retrieve `Posts` along with their associated `Author` data, but you only want to include `Authors` where the `isActive` field is `true` and the `profilePicture` field is `null` (meaning the author doesn't have a profile picture set). You can achieve this with the following query string:

> ?join=**author**||**fullName**,**email**||on[0]=**author.isActive**||**\$eq**||**true**&on[1]=**author.profilePicture**||**\$isnull**
This query will join the `Post` entity with its related `Author` entity, but it will only include `Author` objects where:

- The `isActive` field is set to `true`.
- The `profilePicture` field is `null`.

### limit

Receive `N` amount of entities.
Expand Down Expand Up @@ -330,10 +347,18 @@ qb.setFilter({ field: "foo", operator: CondOperator.NOT_NULL })
value: "test"
});

qb.select(["foo", "bar"])
.setJoin({ field: "company" })
.setJoin({ field: "profile", select: ["name", "email"] })
.sortBy({ field: "bar", order: "DECS" })
qb.select(['foo', 'bar'])
.setJoin({ field: 'company' })
.setJoin({ field: 'profile', select: ['name', 'email'] })
.setJoin({
field: 'boo',
select: ['status', 'date'],
on: [
{ field: 'bar', operator: 'eq', value: 100 },
{ field: 'baz', operator: 'isnull' },
],
})
.sortBy({ field: 'bar', order: 'DECS' })
.setLimit(20)
.setPage(3)
.resetCache()
Expand Down
34 changes: 27 additions & 7 deletions packages/crud-request/src/request-query.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
isString,
isUndefined,
} from '@dataui/crud-util';
import { stringify } from 'qs';
import { IStringifyOptions, stringify } from 'qs';

import {
CreateQueryParams,
Expand Down Expand Up @@ -58,6 +58,11 @@ export class RequestQueryBuilder {
private paramNames: {
[key in keyof RequestQueryBuilderOptions['paramNamesMap']]: string;
} = {};
private joinConditionString: IStringifyOptions = {
encode: false,
delimiter: this.options.delimStr,
arrayFormat: 'indices',
};
public queryObject: { [key: string]: any } = {};
public queryString: string;

Expand Down Expand Up @@ -203,13 +208,28 @@ export class RequestQueryBuilder {
);
}

private addJoin(j: QueryJoin | QueryJoinArr): string {
const join = Array.isArray(j) ? { field: j[0], select: j[1] } : j;
validateJoin(join);
const d = this.options.delim;
const ds = this.options.delimStr;
private addJoin(join: QueryJoin | QueryJoinArr): string {
const { delim, delimStr } = this.options;

const normalizedJoin = Array.isArray(join)
? { field: join[0], select: join[1], on: join[2] }
: join;

validateJoin(normalizedJoin);

const conditions = isArrayFull(normalizedJoin.on)
? { on: normalizedJoin.on.map((condition) => this.cond(condition, 'filter')) }
: '';

const fieldPart = normalizedJoin.field;
const selectPart = isArrayFull(normalizedJoin.select)
? delim + normalizedJoin.select.join(delimStr)
: '';
const conditionsPart = conditions
? delim + stringify(conditions, this.joinConditionString)
: '';

return join.field + (isArrayFull(join.select) ? d + join.select.join(ds) : '');
return fieldPart + selectPart + conditionsPart;
}

private addSortBy(s: QuerySort | QuerySortArr): string {
Expand Down
22 changes: 20 additions & 2 deletions packages/crud-request/src/request-query.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
SConditionAND,
SFields,
} from './types';
import { IParseOptions, parse } from 'qs';

// tslint:disable:variable-name ban-types
export class RequestQueryParser implements ParsedRequestParams {
Expand All @@ -66,6 +67,10 @@ export class RequestQueryParser implements ParsedRequestParams {
private _paramNames: string[];
private _paramsOptions: ParamsOptions;

private _joinConditionParseOptions: IParseOptions = {
delimiter: this._options.delimStr,
};

private get _options(): RequestQueryBuilderOptions {
return RequestQueryBuilder.getOptions();
}
Expand Down Expand Up @@ -350,12 +355,25 @@ export class RequestQueryParser implements ParsedRequestParams {
return condition;
}

private parseJoinConditions(conditionsString: string): QueryFilter[] {
const conditions: string[] = parse(conditionsString, this._joinConditionParseOptions)[
'on'
];
return conditions.map((cond: string) => this.conditionParser('filter', {}, cond));
}

private joinParser(data: string): QueryJoin {
const param = data.split(this._options.delim);
const field = param[0];
const selectString = param[1];
const conditions = param.slice(2).join(this._options.delim);

const join: QueryJoin = {
field: param[0],
select: isStringFull(param[1]) ? param[1].split(this._options.delimStr) : undefined,
field,
select: selectString ? selectString.split(this._options.delimStr) : undefined,
on: isStringFull(conditions) ? this.parseJoinConditions(conditions) : undefined,
};

validateJoin(join);

return join;
Expand Down
4 changes: 4 additions & 0 deletions packages/crud-request/src/request-query.validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isArrayFull,
isArrayStrings,
isEqual,
isNil,
Expand Down Expand Up @@ -89,6 +90,9 @@ export function validateJoin(join: QueryJoin): void {
if (!isUndefined(join.select) && !isArrayStrings(join.select)) {
throw new RequestQueryException('Invalid join select. Array of strings expected');
}
if (!isUndefined(join.on) && !isArrayFull(join.on)) {
join.on.map((condition) => validateCondition(condition, 'filter', {}));
}
}

export function validateSort(sort: QuerySort): void {
Expand Down
3 changes: 2 additions & 1 deletion packages/crud-request/src/types/request-query.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ export type QueryFilterArr = [string, ComparisonOperator, any?];
export interface QueryJoin {
field: string;
select?: QueryFields;
on?: QueryFilter[];
}

export type QueryJoinArr = [string, QueryFields?];
export type QueryJoinArr = [string, QueryFields?, QueryFilter[]?];

export interface QuerySort {
field: string;
Expand Down
75 changes: 69 additions & 6 deletions packages/crud-request/test/request-query.builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ describe('#request-query', () => {
expect(qb.queryObject.filter).toIncludeSameMembers(expected);
});
it('should set filter, 4', () => {
qb.setFilter([['foo', 'eq', 'bar'], ['baz', 'ne', 'zoo']]);
qb.setFilter([
['foo', 'eq', 'bar'],
['baz', 'ne', 'zoo'],
]);
const expected = ['foo||eq||bar', 'baz||ne||zoo'];
expect(qb.queryObject.filter).toIncludeSameMembers(expected);
});
Expand Down Expand Up @@ -155,6 +158,15 @@ describe('#request-query', () => {
it('should throw an error, 3', () => {
expect((qb.setJoin as any).bind(qb, [{}])).toThrowError(RequestQueryException);
});
it('should throw an error, 4', () => {
expect(
(qb.setJoin as any).bind(qb, {
field: 'bar',
select: ['a', 'b', 'c'],
on: [{}],
}),
).toThrowError(RequestQueryException);
});
it('should set join, 1', () => {
qb.setJoin({ field: 'foo' });
const expected = ['foo'];
Expand All @@ -180,6 +192,40 @@ describe('#request-query', () => {
const expected = ['baz', 'foo||a,b,c'];
expect(qb.queryObject.join).toIncludeSameMembers(expected);
});
it('should set join, 6', () => {
qb.setJoin([
['baz'],
['foo', ['a', 'b', 'c']],
['boo', ['a', 'b', 'c'], [{ field: 'bar', operator: 'eq', value: 100 }]],
]);
const expected = ['baz', 'foo||a,b,c', 'boo||a,b,c||on[0]=bar||eq||100'];
expect(qb.queryObject.join).toIncludeSameMembers(expected);
});
it('should set join, 7', () => {
qb.setJoin([
{
field: 'baz',
select: ['a', 'b', 'c'],
on: [{ field: 'bar', operator: 'eq', value: 100 }],
},
]);
const expected = ['baz||a,b,c||on[0]=bar||eq||100'];
expect(qb.queryObject.join).toIncludeSameMembers(expected);
});
it('should set join, 8', () => {
qb.setJoin([
{
field: 'baz',
select: ['a', 'b', 'c'],
on: [
{ field: 'bar', operator: 'eq', value: 100 },
{ field: 'foo', operator: 'isnull' },
],
},
]);
const expected = ['baz||a,b,c||on[0]=bar||eq||100,on[1]=foo||isnull'];
expect(qb.queryObject.join).toIncludeSameMembers(expected);
});
});

describe('#sortBy', () => {
Expand All @@ -206,7 +252,10 @@ describe('#request-query', () => {
expect(qb.queryObject.sort).toIncludeSameMembers(expected);
});
it('should set sort, 2', () => {
qb.sortBy([{ field: 'foo', order: 'ASC' }, { field: 'bar', order: 'DESC' }]);
qb.sortBy([
{ field: 'foo', order: 'ASC' },
{ field: 'bar', order: 'DESC' },
]);
const expected = ['foo,ASC', 'bar,DESC'];
expect(qb.queryObject.sort).toIncludeSameMembers(expected);
});
Expand Down Expand Up @@ -329,7 +378,14 @@ describe('#request-query', () => {
.select(['foo', 'bar'])
.setFilter(['is', 'notnull'])
.setOr({ field: 'ok', operator: 'ne', value: false })
.setJoin({ field: 'voo', select: ['h', 'data'] })
.setJoin({
field: 'voo',
select: ['h', 'data'],
on: [
{ field: 'foo', operator: 'eq', value: 'baz' },
{ field: 'bar', operator: 'isnull' },
],
})
.setLimit(1)
.setOffset(2)
.setPage(3)
Expand All @@ -338,7 +394,7 @@ describe('#request-query', () => {
.setIncludeDeleted(1)
.query(false);
const expected =
'fields=foo,bar&filter[0]=is||notnull&or[0]=ok||ne||false&join[0]=voo||h,data&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0&include_deleted=1';
'fields=foo,bar&filter[0]=is||notnull&or[0]=ok||ne||false&join[0]=voo||h,data||on[0]=foo||eq||baz,on[1]=bar||isnull&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0&include_deleted=1';
expect(test).toBe(expected);
});
});
Expand Down Expand Up @@ -375,15 +431,22 @@ describe('#request-query', () => {
fields: ['foo', 'bar'],
filter: ['is', 'notnull'],
or: { field: 'ok', operator: 'ne', value: false },
join: { field: 'voo', select: ['h', 'data'] },
join: {
field: 'voo',
select: ['h', 'data'],
on: [
{ field: 'foo', operator: 'eq', value: 'baz' },
{ field: 'bar', operator: 'isnull' },
],
},
limit: 1,
offset: 2,
page: 3,
sort: [['foo', 'DESC']],
resetCache: true,
}).query(false);
const expected =
'fields=foo,bar&filter[0]=is||notnull&or[0]=ok||ne||false&join[0]=voo||h,data&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0';
'fields=foo,bar&filter[0]=is||notnull&or[0]=ok||ne||false&join[0]=voo||h,data||on[0]=foo||eq||baz,on[1]=bar||isnull&limit=1&offset=2&page=3&sort[0]=foo,DESC&cache=0';
expect(test).toBe(expected);
});
it('should return a valid query string, 2', () => {
Expand Down
26 changes: 26 additions & 0 deletions packages/crud-request/test/request-query.parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,32 @@ describe('#request-query', () => {
expect(test.join[0]).toMatchObject(expected[0]);
expect(test.join[1]).toMatchObject(expected[1]);
});
it('should set array, 3', () => {
const query = {
join: [
'foo',
'bar||baz,boo',
'bar||baz,boo||on[0]=name||eq||jhon,on[1]=foo||isnull',
],
};
const expected: QueryJoin[] = [
{ field: 'foo' },
{ field: 'bar', select: ['baz', 'boo'] },
{
field: 'bar',
select: ['baz', 'boo'],
on: [
{ field: 'name', operator: 'eq', value: 'jhon' },
{ field: 'foo', operator: 'isnull', value: '' },
],
},
];
const test = qp.parseQuery(query);

expect(test.join[0]).toMatchObject(expected[0]);
expect(test.join[1]).toMatchObject(expected[1]);
expect(test.join[2]).toMatchObject(expected[2]);
});
});

describe('#parse sort', () => {
Expand Down
28 changes: 27 additions & 1 deletion packages/crud-typeorm/src/typeorm-crud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,24 @@ export class TypeOrmCrudService<T> extends CrudService<T, DeepPartial<T>> {
}
}

private convertArrayToQuery(data: { str: string; params: { [key: string]: any } }[]): {
str: string;
params: { [key: string]: any };
} {
const queryStringParts: string[] = [];
const params: { [key: string]: any } = {};

for (const item of data) {
const { str, params: itemParams } = item;
queryStringParts.push(str);
Object.assign(params, itemParams);
}

const combinedString = queryStringParts.join(' AND ');

return { str: combinedString, params };
}

protected setJoin(
cond: QueryJoin,
joinOptions: JoinOptions,
Expand Down Expand Up @@ -637,7 +655,15 @@ export class TypeOrmCrudService<T> extends CrudService<T, DeepPartial<T>> {
const relationType = options.required ? 'innerJoin' : 'leftJoin';
const alias = options.alias ? options.alias : allowedRelation.name;

builder[relationType](allowedRelation.path, alias);
if (cond.on) {
const conds = cond.on.map((condition, i) =>
this.mapOperatorsToQuery(condition, `andCondition${i}`, {}),
);
const { str, params } = this.convertArrayToQuery(conds);
builder[relationType](allowedRelation.path, alias, str, params);
} else {
builder[relationType](allowedRelation.path, alias);
}

if (options.select !== false) {
const columns = isArrayFull(cond.select)
Expand Down
12 changes: 12 additions & 0 deletions packages/crud-typeorm/test/__fixture__/UserProfile.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';

import { TypeOrmCrudService } from '../../src/typeorm-crud.service';
import { UserProfile } from '../../../../integration/crud-typeorm/users-profiles';

@Injectable()
export class UserProfilesService extends TypeOrmCrudService<UserProfile> {
constructor(@InjectRepository(UserProfile) repo) {
super(repo);
}
}
Loading

0 comments on commit d1e1357

Please sign in to comment.