Skip to content

Commit

Permalink
feat(sql): support multiple conditions in JOINs (#94)
Browse files Browse the repository at this point in the history
Closes #70
  • Loading branch information
B4nan committed Aug 15, 2019
1 parent 7aed268 commit 29b5561
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 48 deletions.
20 changes: 11 additions & 9 deletions lib/query/QueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,30 +80,31 @@ export class QueryBuilder {
return this.init(QueryType.COUNT);
}

join(field: string, alias: string, type: 'leftJoin' | 'innerJoin' = 'innerJoin'): this {
join(field: string, alias: string, cond: Record<string, any> = {}, type: 'leftJoin' | 'innerJoin' = 'innerJoin'): this {
const [fromAlias, fromField] = this.helper.splitField(field);
const entityName = this._aliasMap[fromAlias];
const prop = this.metadata.get(entityName).properties[fromField];
this._aliasMap[alias] = prop.type;
cond = SmartQueryHelper.processWhere(cond, this.entityName, this.metadata.get(this.entityName));

if (prop.reference === ReferenceType.ONE_TO_MANY) {
this._joins[prop.name] = this.helper.joinOneToReference(prop, fromAlias, alias, type);
this._joins[prop.name] = this.helper.joinOneToReference(prop, fromAlias, alias, type, cond);
} else if (prop.reference === ReferenceType.MANY_TO_MANY) {
const pivotAlias = `e${this.aliasCounter++}`;
const joins = this.helper.joinManyToManyReference(prop, fromAlias, alias, pivotAlias, type);
const joins = this.helper.joinManyToManyReference(prop, fromAlias, alias, pivotAlias, type, cond);
this._fields.push(`${pivotAlias}.${prop.name}`);
Object.assign(this._joins, joins);
} else if (prop.reference === ReferenceType.ONE_TO_ONE) {
this._joins[prop.name] = this.helper.joinOneToReference(prop, fromAlias, alias, type);
this._joins[prop.name] = this.helper.joinOneToReference(prop, fromAlias, alias, type, cond);
} else { // MANY_TO_ONE
this._joins[prop.name] = this.helper.joinManyToOneReference(prop, fromAlias, alias, type);
this._joins[prop.name] = this.helper.joinManyToOneReference(prop, fromAlias, alias, type, cond);
}

return this;
}

leftJoin(field: string, alias: string): this {
return this.join(field, alias, 'leftJoin');
leftJoin(field: string, alias: string, cond: Record<string, any> = {}): this {
return this.join(field, alias, cond, 'leftJoin');
}

where(cond: Record<string, any>, operator?: keyof typeof QueryBuilderHelper.GROUP_OPERATORS): this; // tslint:disable-next-line:lines-between-class-members
Expand Down Expand Up @@ -269,7 +270,7 @@ export class QueryBuilder {
return ret.push(...this.helper.mapJoinColumns(this.type, this._joins[f]) as string[]);
}

ret.push(this.helper.mapper(this.type, f) as string);
ret.push(this.helper.mapper(f, this.type) as string);
});

Object.keys(this._populateMap).forEach(f => {
Expand Down Expand Up @@ -383,7 +384,7 @@ export class QueryBuilder {
break;
case QueryType.COUNT:
const m = this.flags.includes(QueryFlag.DISTINCT) ? 'countDistinct' : 'count';
qb[m](this.helper.mapper(this.type, this._fields[0], undefined, 'count'));
qb[m](this.helper.mapper(this._fields[0], this.type, undefined, 'count'));
this.helper.processJoins(qb, this._joins);
break;
case QueryType.INSERT:
Expand Down Expand Up @@ -450,4 +451,5 @@ export interface JoinOptions {
inverseJoinColumn?: string;
primaryKey?: string;
prop: EntityProperty;
cond: Record<string, any>;
}
114 changes: 94 additions & 20 deletions lib/query/QueryBuilderHelper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Knex, { QueryBuilder as KnexQueryBuilder, Raw } from 'knex';
import Knex, { JoinClause, QueryBuilder as KnexQueryBuilder, Raw } from 'knex';
import { Utils, ValidationError } from '../utils';
import { EntityMetadata, EntityProperty } from '../decorators';
import { QueryOrderMap, QueryOrderNumeric, QueryType } from './enums';
Expand Down Expand Up @@ -33,7 +33,10 @@ export class QueryBuilderHelper {
private readonly knex: Knex,
private readonly platform: Platform) { }

mapper(type: QueryType, field: string, value?: any, alias?: string): string | Raw {
mapper(field: string, type?: QueryType): string; // tslint:disable-next-line:lines-between-class-members
mapper(field: string, type?: QueryType, value?: undefined, alias?: string): string; // tslint:disable-next-line:lines-between-class-members
mapper(field: string, type?: QueryType, value?: any, alias?: string): string; // tslint:disable-next-line:lines-between-class-members
mapper(field: string, type = QueryType.SELECT, value?: any, alias?: string): string | Knex.Raw {
let ret = field;
const customExpression = this.isCustomExpression(field);

Expand Down Expand Up @@ -78,7 +81,7 @@ export class QueryBuilderHelper {
return data;
}

joinOneToReference(prop: EntityProperty, ownerAlias: string, alias: string, type: 'leftJoin' | 'innerJoin'): JoinOptions {
joinOneToReference(prop: EntityProperty, ownerAlias: string, alias: string, type: 'leftJoin' | 'innerJoin', cond: Record<string, any> = {}): JoinOptions {
const meta = this.metadata.get(prop.type);
const prop2 = meta.properties[prop.mappedBy || prop.inversedBy];

Expand All @@ -91,10 +94,11 @@ export class QueryBuilderHelper {
alias,
prop,
type,
cond,
};
}

joinManyToOneReference(prop: EntityProperty, ownerAlias: string, alias: string, type: 'leftJoin' | 'innerJoin'): JoinOptions {
joinManyToOneReference(prop: EntityProperty, ownerAlias: string, alias: string, type: 'leftJoin' | 'innerJoin', cond: Record<string, any> = {}): JoinOptions {
return {
table: this.getTableName(prop.type),
joinColumn: prop.inverseJoinColumn,
Expand All @@ -103,12 +107,14 @@ export class QueryBuilderHelper {
alias,
prop,
type,
cond,
};
}

joinManyToManyReference(prop: EntityProperty, ownerAlias: string, alias: string, pivotAlias: string, type: 'leftJoin' | 'innerJoin'): Record<string, JoinOptions> {
joinManyToManyReference(prop: EntityProperty, ownerAlias: string, alias: string, pivotAlias: string, type: 'leftJoin' | 'innerJoin', cond: Record<string, any>): Record<string, JoinOptions> {
const join = {
type,
cond,
ownerAlias,
alias: pivotAlias,
joinColumn: prop.joinColumn,
Expand Down Expand Up @@ -138,8 +144,9 @@ export class QueryBuilderHelper {
return ret;
}

joinPivotTable(field: string, prop: EntityProperty, ownerAlias: string, alias: string, type: 'leftJoin' | 'innerJoin'): JoinOptions {
joinPivotTable(field: string, prop: EntityProperty, ownerAlias: string, alias: string, type: 'leftJoin' | 'innerJoin', cond: Record<string, any> = {}): JoinOptions {
const prop2 = this.metadata.get(field).properties[prop.mappedBy || prop.inversedBy];

return {
table: this.metadata.get(field).collection,
joinColumn: prop.joinColumn,
Expand All @@ -149,6 +156,7 @@ export class QueryBuilderHelper {
alias,
prop,
type,
cond,
};
}

Expand All @@ -157,18 +165,26 @@ export class QueryBuilderHelper {
const table = `${join.table} as ${join.alias}`;
const left = `${join.ownerAlias}.${join.primaryKey!}`;
const right = `${join.alias}.${join.joinColumn!}`;

if (join.cond) {
return qb[join.type](table, inner => {
inner.on(left, right);
this.appendJoinClause(inner, join.cond);
});
}

qb[join.type](table, left, right);
});
}

mapJoinColumns(type: QueryType, join: JoinOptions): (string | Raw)[] {
if (join.prop && join.prop.reference === ReferenceType.ONE_TO_ONE && !join.prop.owner) {
return [this.mapper(type, `${join.alias}.${join.inverseJoinColumn}`, undefined, join.prop.fieldName)];
return [this.mapper(`${join.alias}.${join.inverseJoinColumn}`, type, undefined, join.prop.fieldName)];
}

return [
this.mapper(type, `${join.alias}.${join.joinColumn}`),
this.mapper(type, `${join.alias}.${join.inverseJoinColumn}`),
this.mapper(`${join.alias}.${join.joinColumn}`, type),
this.mapper(`${join.alias}.${join.inverseJoinColumn}`, type),
];
}

Expand Down Expand Up @@ -226,36 +242,38 @@ export class QueryBuilderHelper {
const m = operator === '$or' ? 'orWhere' : method;

if (cond[key] instanceof RegExp) {
return void qb[m](this.mapper(type, key) as string, 'like', this.getRegExpParam(cond[key]));
return void qb[m](this.mapper(key, type), 'like', this.getRegExpParam(cond[key]));
}

if (Utils.isObject(cond[key]) && !(cond[key] instanceof Date)) {
return this.processObjectSubCondition(cond, key, qb, method, m, type);
}

if (this.isCustomExpression(key)) {
return this.processCustomExpression(key, cond, qb, m, type);
return this.processCustomExpression(qb, m, key, cond, type);
}

const op = cond[key] === null ? 'is' : '=';

qb[m](this.mapper(type, key, cond[key]) as string, op, cond[key]);
qb[m](this.mapper(key, type, cond[key]), op, cond[key]);
}

private processCustomExpression(key: string, cond: any, qb: KnexQueryBuilder, m: 'where' | 'orWhere' | 'having', type: QueryType): void {
private processCustomExpression<T extends any[] = any[]>(clause: any, m: string, key: string, cond: any, type = QueryType.SELECT): void {
// unwind parameters when ? found in field name
const count = key.concat('?').match(/\?/g)!.length - 1;
const params1 = cond[key].slice(0, count).map((c: any) => Utils.isObject(c) ? JSON.stringify(c) : c);
const params2 = cond[key].slice(count);
const value = Utils.asArray(cond[key]);
const params1 = value.slice(0, count).map((c: any) => Utils.isObject(c) ? JSON.stringify(c) : c);
const params2 = value.slice(count);
const k = this.mapper(key, type, params1);

if (params2.length > 0) {
return void qb[m](this.mapper(type, key, params1) as string, params2);
return void clause[m](k, this.knex.raw('?', params2));
}

qb[m](this.mapper(type, key, params1) as string);
clause[m](k);
}

private processObjectSubCondition(cond: any, key: string, qb: Knex.QueryBuilder, method: 'where' | 'having', m: 'where' | 'orWhere' | 'having', type: QueryType): void {
private processObjectSubCondition(cond: any, key: string, qb: KnexQueryBuilder, method: 'where' | 'having', m: 'where' | 'orWhere' | 'having', type: QueryType): void {
// grouped condition for one field
if (Object.keys(cond[key]).length > 1) {
const subCondition = Object.entries(cond[key]).map(([subKey, subValue]) => ({ [key]: { [subKey]: subValue } }));
Expand All @@ -268,7 +286,63 @@ export class QueryBuilderHelper {
continue;
}

qb[m](this.mapper(type, key) as string, replacement, cond[key][op]);
qb[m](this.mapper(key, type), replacement, cond[key][op]);

break;
}
}

private appendJoinClause(clause: JoinClause, cond: Record<string, any>, operator?: '$and' | '$or'): void {
Object.keys(cond).forEach(k => {
if (k === '$and' || k === '$or') {
const method = operator === '$or' ? 'orOn' : 'andOn';
const m = k === '$or' ? 'orOn' : 'andOn';
return clause[method](outer => cond[k].forEach((sub: any) => {
if (Object.keys(sub).length === 1) {
return this.appendJoinClause(outer, sub, k);
}

outer[m](inner => this.appendJoinClause(inner, sub, '$and'));
}));
}

this.appendJoinSubClause(clause, cond, k, operator);
});
}

private appendJoinSubClause(clause: JoinClause, cond: any, key: string, operator?: '$and' | '$or'): void {
const m = operator === '$or' ? 'orOn' : 'andOn';

if (cond[key] instanceof RegExp) {
return void clause[m](this.mapper(key), 'like', this.knex.raw('?', this.getRegExpParam(cond[key])));
}

if (Utils.isObject(cond[key]) && !(cond[key] instanceof Date)) {
return this.processObjectSubClause(cond, key, clause, m);
}

if (this.isCustomExpression(key)) {
return this.processCustomExpression(clause, m, key, cond);
}

const op = cond[key] === null ? 'is' : '=';
clause[m](this.knex.raw(`${this.knex.ref(this.mapper(key, QueryType.SELECT, cond[key]))} ${op} ?`, cond[key]));
}

private processObjectSubClause(cond: any, key: string, clause: JoinClause, m: 'andOn' | 'orOn'): void {
// grouped condition for one field
if (Object.keys(cond[key]).length > 1) {
const subCondition = Object.entries(cond[key]).map(([subKey, subValue]) => ({ [key]: { [subKey]: subValue } }));
return void clause[m](inner => subCondition.map((sub: any) => this.appendJoinClause(inner, sub, '$and')));
}

// operators
for (const [op, replacement] of Object.entries(QueryBuilderHelper.OPERATORS)) {
if (!(op in cond[key])) {
continue;
}

clause[m](this.mapper(key), replacement, this.knex.raw('?', cond[key][op]));

break;
}
Expand All @@ -287,7 +361,7 @@ export class QueryBuilderHelper {
const direction = orderBy[k];
const order = Utils.isNumber<QueryOrderNumeric>(direction) ? QueryOrderNumeric[direction] : direction;

return { column: this.mapper(type, `${alias}.${field}`) as string, order: order.toLowerCase() };
return { column: this.mapper(`${alias}.${field}`, type), order: order.toLowerCase() };
});
}

Expand Down
30 changes: 15 additions & 15 deletions lib/query/SmartQueryHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,35 +36,35 @@ export class SmartQueryHelper {
return { [rootPrimaryKey]: { $in: where.map(sub => this.processWhere(sub, entityName, meta)) } };
}

if (!Utils.isObject(where)) {
if (!Utils.isObject(where) || Utils.isPrimaryKey(where)) {
return where;
}

Object.entries(where).forEach(([key, value]) => {
if (QueryBuilderHelper.GROUP_OPERATORS[key as '$and' | '$or']) {
return value.map((sub: any) => this.processWhere(sub, entityName, meta));
return Object.keys(where).reduce((o, key) => {
const value = where[key as keyof typeof where];

if (key in QueryBuilderHelper.GROUP_OPERATORS) {
o[key] = value.map((sub: any) => this.processWhere(sub, entityName, meta));
return o;
}

if (Array.isArray(value) && !SmartQueryHelper.isSupported(key) && !key.includes('?')) {
return where[key as keyof typeof where] = { $in: value };
o[key] = { $in: value };
return o;
}

if (!SmartQueryHelper.isSupported(key)) {
return where;
}

delete where[key as keyof typeof where];

if (key.includes(':')) {
o[key] = where[key as keyof typeof where];
} else if (key.includes(':')) {
const [k, expr] = key.split(':');
where[k as keyof typeof where] = SmartQueryHelper.processExpression(expr, value);
o[k] = SmartQueryHelper.processExpression(expr, value);
} else {
const m = key.match(/([\w-]+) ?([<>=!]+)$/)!;
where[m[1] as keyof typeof where] = SmartQueryHelper.processExpression(m[2], value);
o[m[1]] = SmartQueryHelper.processExpression(m[2], value);
}
});

return where;
return o;
}, {} as Record<string, any>);
}

private static processEntity(entity: IEntity, root?: boolean): any {
Expand Down
Loading

0 comments on commit 29b5561

Please sign in to comment.