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

Enhanced Data Retrieval: Implement Join Conditions #43

Merged
merged 10 commits into from
Apr 9, 2024
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:

# Runs a single command using the runners shell
- name: build docker db
run: docker-compose up -d
run: docker compose up -d

- name: install
run: yarn install
Expand All @@ -38,7 +38,7 @@ jobs:
run: yarn build

- name: check docker
run: docker-compose up -d
run: docker compose up -d

# Runs a set of commands using the runners shell
- name: tests
Expand Down
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
Loading
Loading