Skip to content
Permalink
Browse files
refactor(knex): rebuild knex query mapper implementation
BREAKING
  • Loading branch information
Enda Phelan committed Sep 14, 2020
1 parent 1ebe7e9 commit 6d43f288865a2c8c0d441e486a156301ca6cc42a
Show file tree
Hide file tree
Showing 4 changed files with 926 additions and 165 deletions.
@@ -1,6 +1,6 @@
import { buildModelTableMap, getDatabaseArguments, ModelTableMap, GraphbackContext, GraphbackDataProvider, GraphbackOrderBy, GraphbackPage, NoDataError, QueryFilter, ModelDefinition, FindByArgs } from '@graphback/core';
import { buildModelTableMap, getDatabaseArguments, ModelTableMap, GraphbackDataProvider, NoDataError, QueryFilter, ModelDefinition, FindByArgs, GraphbackPage } from '@graphback/core';
import * as Knex from 'knex';
import { buildQuery } from './knexQueryMapper';
import { CRUDKnexQueryMapper, createKnexQueryMapper } from './knexQueryMapper';

/**
* Knex.js database data provider exposing basic CRUD operations that works with all databases that knex supports.
@@ -18,9 +18,11 @@ export class KnexDBDataProvider<Type = any> implements GraphbackDataProvider<Typ
protected db: Knex;
protected tableName: string;
protected tableMap: ModelTableMap;
protected queryBuilder: CRUDKnexQueryMapper;

public constructor(model: ModelDefinition, db: Knex) {
this.db = db;
this.queryBuilder = createKnexQueryMapper(db);
this.tableMap = buildModelTableMap(model.graphqlType);
this.tableName = this.tableMap.tableName;
}
@@ -70,7 +72,7 @@ export class KnexDBDataProvider<Type = any> implements GraphbackDataProvider<Typ
}

public async findBy(args?: FindByArgs, selectedFields?: string[]): Promise<Type[]> {
let query = buildQuery(this.db, args?.filter).select(this.getSelectedFields(selectedFields)).from(this.tableName)
let query = this.queryBuilder.buildQuery(args?.filter).select(this.getSelectedFields(selectedFields)).from(this.tableName)

if (args?.orderBy) {
query = query.orderBy(args.orderBy.field, args.orderBy.order)
@@ -86,14 +88,14 @@ export class KnexDBDataProvider<Type = any> implements GraphbackDataProvider<Typ
}

public async count(filter?: QueryFilter): Promise<number> {
const dbResult = await buildQuery(this.db, filter).from(this.tableName).count();
const dbResult = await this.queryBuilder.buildQuery(filter).from(this.tableName).count();
const count: any = Object.values(dbResult[0])[0];

return parseInt(count, 10);
}

public async batchRead(relationField: string, ids: string[], filter?: QueryFilter, selectedFields?: string[]): Promise<Type[][]> {
const dbResult = await buildQuery(this.db, filter).select(this.getSelectedFields(selectedFields)).from(this.tableName).whereIn(relationField, ids);
const dbResult = await this.queryBuilder.buildQuery(filter).select(this.getSelectedFields(selectedFields)).from(this.tableName).whereIn(relationField, ids);

if (dbResult) {
const resultsById = ids.map((id: string) => dbResult.filter((data: any) => {
@@ -1,140 +1,168 @@
import Knex from 'knex';
import { QueryFilter, QueryFilterOperator } from '@graphback/core';

interface OperatorMap {
ne: '<>',
eq: '=',
le: '<=',
lt: '<',
ge: '>=',
gt: '>',
contains: 'like',
startswith: 'like',
endswith: 'like',
}
const knexOperators = ['=', '<>', '<=', '<', '>', '>=', 'like', 'between', 'in'] as const;
const knexMethods = ['where', 'orWhere', 'orWhereNot', 'whereNot'] as const;

const methodMapping = {
in: 'whereIn',
between: 'whereBetween',
default: 'where'
}
type KnexQueryOperator = typeof knexOperators[number];
type KnexMethod = typeof knexMethods[number];
type KnexWhereConditionMap = [KnexQueryOperator, any];
type RootQuerySelector = 'and' | 'or' | 'not';

const notMethodMap = {
where: 'whereNot',
whereNull: 'whereNotNull',
between: 'whereNotBetween',
in: 'whereNotIn'
}
type KnexRootQuerySelectorBuilderFn = (builder: Knex.QueryBuilder, filter: QueryFilter | QueryFilter[], rootSelectorContext: RootQuerySelectorContext) => Knex.QueryBuilder;
type KnexQueryBuilderMapFn = (builder: Knex.QueryBuilder, filter: QueryFilter | QueryFilter[]) => Knex.QueryBuilder;

const AND_FIELD = 'and';
const OR_FIELD = 'or';
const NOT_FIELD = 'not';

function mapOperator(operator: any) {
const operatorMap: OperatorMap = {
ne: '<>',
eq: '=',
le: '<=',
lt: '<',
ge: '>=',
gt: '>',
contains: 'like',
startswith: 'like',
endswith: 'like'
}
interface RootQuerySelectorContext {
and?: boolean
or?: boolean
not?: boolean
}

const oprLower = operator.toLowerCase()
export interface CRUDKnexQueryMapper {
/**
* Maps a Graphback QueryFilter to a Knex query
*
* @param {QueryFilter} [filter] - input filter
*/
buildQuery(filter?: QueryFilter): Knex.QueryBuilder;
}

if (!Object.keys(operatorMap).includes(oprLower)) {
throw Error(`Not supported operator: ${operator}`)
const mapQueryFilterOperatorToKnexWhereCondition = (operator: QueryFilterOperator, value: any): KnexWhereConditionMap => {
const operatorToWhereConditionMap: { [key in QueryFilterOperator]: KnexWhereConditionMap } = {
eq: ['=', value],
ne: ['<>', value],
lt: ['<', value],
le: ['<=', value],
ge: ['>=', value],
gt: ['>', value],
contains: ['like', `%${value}%`],
startsWith: ['like', `${value}%`],
endsWith: ['like', `%${value}`],
in: ['in', value],
between: ['between', value]
}

return operatorMap[oprLower]
return operatorToWhereConditionMap[operator];
}

function builderMethod(method: string, or?: boolean, not?: boolean) {
if (!not && methodMapping[method]) {
method = methodMapping[method]
} else if (not && notMethodMap[method]) {
method = notMethodMap[method]
function buildKnexMethod(rootSelectorContext: RootQuerySelectorContext): KnexMethod {
let method = 'where';
if (rootSelectorContext?.not) {
method = `${method}Not`
}

if (or) {
return `or${method.charAt(0).toUpperCase()}${method.slice(1)}`
if (rootSelectorContext?.or) {
method = `or${method.charAt(0).toUpperCase()}${method.slice(1)}`;
}

return method
return method as KnexMethod
}

function where(builder: Knex.QueryBuilder, filter: any, or: boolean = false, not: boolean = false) {
if (!filter) {
return builder
const rootSelectorMapper: { [key in RootQuerySelector]: KnexRootQuerySelectorBuilderFn } = {
and: (builder: Knex.QueryBuilder, filters: QueryFilter[], rootSelectorContext: RootQuerySelectorContext): Knex.QueryBuilder => {
builder = builder.where((b: Knex.QueryBuilder) => {
filters.forEach((f: QueryFilter) => mapQuery(b, f, { ...rootSelectorContext, and: true }));
});

return builder;
},
or: (builder: Knex.QueryBuilder, filters: QueryFilter[], rootSelectorContext: RootQuerySelectorContext): Knex.QueryBuilder => {
builder = builder.where((b: Knex.QueryBuilder) => {
filters.forEach((f: QueryFilter) => mapQuery(b, f, { ...rootSelectorContext, or: true }));
});

return builder;
},
not: (builder: Knex.QueryBuilder, filter: QueryFilter, rootSelectorContext: RootQuerySelectorContext): Knex.QueryBuilder => {
builder = builder.where((b: Knex.QueryBuilder) => {
builder = mapQuery(b, filter, { ...rootSelectorContext, not: true });
});

return builder;
},
}

/**
* Wraps Kne methods and pipe the QueryFilter conditions into a final Knex condition
*/
const methodBuilderMapper: { [key in KnexMethod | 'finally']: KnexQueryBuilderMapFn } = {
where: (builder: Knex.QueryBuilder, filter: QueryFilter): Knex.QueryBuilder => {
return builder.where((b: Knex.QueryBuilder) => b = methodBuilderMapper.finally(b, filter));
},
orWhere: (builder: Knex.QueryBuilder, filter: QueryFilter): Knex.QueryBuilder => {
return builder.orWhere((b: Knex.QueryBuilder) => b = methodBuilderMapper.finally(b, filter));
},
whereNot: (builder: Knex.QueryBuilder, filter: QueryFilter): Knex.QueryBuilder => {
return builder.whereNot((b: Knex.QueryBuilder) => b = methodBuilderMapper.finally(b, filter));
},
orWhereNot: (builder: Knex.QueryBuilder, filter: QueryFilter): Knex.QueryBuilder => {
return builder.orWhereNot((b: Knex.QueryBuilder) => b = methodBuilderMapper.finally(b, filter));
},
finally: (builder: Knex.QueryBuilder, filter: QueryFilter): Knex.QueryBuilder => {
Object.entries(filter).forEach(([col, expr]: [string, any]) => {
Object.entries(expr).forEach(([operator, val]: [any, any]) => {
const [mappedOperator, transformedValue] = mapQueryFilterOperatorToKnexWhereCondition(operator, val);
builder = builder.where(col, mappedOperator, transformedValue);
})
});

return builder;
}
}

const andQueries = []
const orQueries = []
const notQueries = []
for (const entry of Object.entries(filter)) {
const col = entry[0]
const expr = entry[1] as any

// collect all AND condition filters
if (col === AND_FIELD) {
const andExpressions = Array.isArray(expr) ? expr : [expr]
andQueries.push(...andExpressions)
continue
}
function mapQuery(builder: Knex.QueryBuilder, filter: QueryFilter, rootSelectorContext: RootQuerySelectorContext = {}): Knex.QueryBuilder {
if (filter === undefined) { return builder };

// collect all OR condition filters
if (col === OR_FIELD) {
const orExpressions = Array.isArray(expr) ? expr : [expr]
orQueries.push(...orExpressions)
continue
}
const mappedQuery = {
rootFieldQueries: {} as QueryFilter,
and: [] as QueryFilter[],
or: [] as QueryFilter[],
not: {} as QueryFilter
}

// collect all NOT condition filters
if (col === NOT_FIELD) {
notQueries.push(expr)
continue
}
for (const key of Object.keys(filter)) {
const rootSelector = rootSelectorMapper[key];

const exprEntry = Object.entries(expr)[0]

// eslint-disable-next-line no-null/no-null
if (exprEntry[1] === null) {
builder = builder[builderMethod('whereNull', or, exprEntry[0] === 'ne')](col)
} else if (Object.keys(methodMapping).includes(exprEntry[0])) {
builder = builder[builderMethod(exprEntry[0], or, not)](col, exprEntry[1])
} else if (exprEntry[0] === 'contains') {
builder = builder[builderMethod('where', or, not)](col, mapOperator(exprEntry[0]), `%${exprEntry[1]}%`)
} else if (exprEntry[0] === 'startsWith') {
builder = builder[builderMethod('where', or, not)](col, mapOperator(exprEntry[0]), `${exprEntry[1]}%`)
} else if (exprEntry[0] === 'endsWith') {
builder = builder[builderMethod('where', or, not)](col, mapOperator(exprEntry[0]), `%${exprEntry[1]}`)
if (rootSelector) {
mappedQuery[key] = filter[key];
} else {
builder = builder[builderMethod('where', or, not)](col, mapOperator(exprEntry[0]), exprEntry[1])
mappedQuery.rootFieldQueries[key] = filter[key];
}
}

// build AND queries
for (const andFilter of andQueries) {
builder = where(builder, andFilter, false, not)
}
// build conditions for root fields
if (Object.keys(mappedQuery.rootFieldQueries).length) {
const knexMethodName = buildKnexMethod(rootSelectorContext);

// build NOT queries
for (const notFilter of notQueries) {
builder = where(builder, notFilter, or, true)
const wrappedKnexMethod = methodBuilderMapper[knexMethodName];

if (wrappedKnexMethod) {
builder = wrappedKnexMethod(builder, mappedQuery.rootFieldQueries);
}
}

// build OR queries
for (const orFilter of orQueries) {
builder = where(builder, orFilter, true, not)
if (mappedQuery.and.length) {
builder = rootSelectorMapper.and(builder, mappedQuery.and, rootSelectorContext);
}
if (mappedQuery.or.length) {
builder = rootSelectorMapper.or(builder, mappedQuery.or, rootSelectorContext);
}
if (Object.keys(mappedQuery.not).length) {
builder = rootSelectorMapper.not(builder, mappedQuery.not, rootSelectorContext);
}

return builder
return builder;
}

export function buildQuery(knex: Knex<any, any>, filter: any): Knex.QueryBuilder {
const builder = where(knex.queryBuilder(), filter)

return builder
/**
* Create an instance of a CRUD => Knex query mapper
* *
* @param {Knex} knex - The current Knex connection instance
*/
export function createKnexQueryMapper(knex: Knex): CRUDKnexQueryMapper {
return {
buildQuery: (filter: QueryFilter): Knex.QueryBuilder => {
return mapQuery(knex.queryBuilder(), filter);
}
}
}

0 comments on commit 6d43f28

Please sign in to comment.