WIP Advanced filter.

This commit is contained in:
Ahmed Bouhuolia
2020-03-23 15:11:07 +02:00
parent 58c67ee36e
commit 52f94c30f5
6 changed files with 414 additions and 47 deletions

View File

@@ -1,6 +1,36 @@
/* eslint-disable quote-props */
export default { export default {
"expense_account": 'expense_account_id', // Expenses.
"payment_account": 'payment_account_id', 'expenses': {
"account_type": "account_type_id" 'expense_account': {
} column: ' ',
relation: 'accounts.name',
},
'payment_account': {
column: 'payment_account_id',
relation: 'accounts.id',
},
'account_type': {
column: 'account_type_id',
relation: 'account_types.id',
},
},
// Accounts
'accounts': {
'name': {
column: 'name',
},
'type': {
column: 'account_type_id',
relation: 'account_types.id',
},
'description': {
column: 'description',
},
'code': {
column: 'code',
},
},
};

View File

@@ -14,6 +14,7 @@ import {
mapViewRolesToConditionals, mapViewRolesToConditionals,
validateViewRoles, validateViewRoles,
} from '@/lib/ViewRolesBuilder'; } from '@/lib/ViewRolesBuilder';
import FilterRoles from '@/lib/FilterRoles';
export default { export default {
/** /**
@@ -215,11 +216,7 @@ export default {
query('account_types.*').optional().isNumeric().toInt(), query('account_types.*').optional().isNumeric().toInt(),
query('custom_view_id').optional().isNumeric().toInt(), query('custom_view_id').optional().isNumeric().toInt(),
query('roles').optional().isArray({ min: 1 }), query('stringified_filter_roles').optional().isJSON(),
query('roles.*.field_key').exists().escape().trim(),
query('roles.*.comparator').exists(),
query('roles.*.value').exists(),
query('roles.*.index').exists().isNumeric().toInt(),
], ],
async handler(req, res) { async handler(req, res) {
const validationErrors = validationResult(req); const validationErrors = validationResult(req);
@@ -233,11 +230,17 @@ export default {
const filter = { const filter = {
account_types: [], account_types: [],
display_type: 'tree', display_type: 'tree',
filter_roles: [],
...req.query, ...req.query,
}; };
if (filter.stringified_filter_roles) {
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
}
const errorReasons = []; const errorReasons = [];
const viewConditionals = []; const viewConditionals = [];
const accountsResource = await Resource.query().where('name', 'accounts').first();
const accountsResource = await Resource.query()
.where('name', 'accounts').withGraphFetched('fields').first();
if (!accountsResource) { if (!accountsResource) {
return res.status(400).send({ return res.status(400).send({
@@ -264,6 +267,15 @@ export default {
errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 }); errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 });
} }
} }
// Validate the accounts resource fields.
const filterRoles = new FilterRoles(Account.tableName,
filter.filter_roles.map((role) => ({ ...role, columnKey: role.fieldKey })),
accountsResource.fields);
if (filterRoles.validateFilterRoles().length > 0) {
errorReasons.push({ type: 'ACCOUNTS.RESOURCE.HAS.NO.GIVEN.FIELDS', code: 500 });
}
if (errorReasons.length > 0) { if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons }); return res.status(400).send({ errors: errorReasons });
} }
@@ -271,9 +283,15 @@ export default {
builder.modify('filterAccountTypes', filter.account_types); builder.modify('filterAccountTypes', filter.account_types);
builder.withGraphFetched('type'); builder.withGraphFetched('type');
if (viewConditionals.length) { // Build custom view conditions query.
if (viewConditionals.length > 0) {
builder.modify('viewRolesBuilder', viewConditionals, view.rolesLogicExpression); builder.modify('viewRolesBuilder', viewConditionals, view.rolesLogicExpression);
} }
// Build filter query.
if (filter.filter_roles.length > 0) {
filterRoles.buildQuery()(builder);
}
}); });
const nestedAccounts = new NestedSet(accounts, { parentId: 'parentAccountId' }); const nestedAccounts = new NestedSet(accounts, { parentId: 'parentAccountId' });

View File

@@ -0,0 +1,46 @@
import { difference } from 'lodash';
import {
buildFilterQuery,
} from '../ViewRolesBuilder';
export default class FilterRoles {
/**
* Constructor method.
* @param {Array} filterRoles -
* @param {Array} resourceFields -
*/
constructor(tableName, filterRoles, resourceFields) {
this.filterRoles = filterRoles.map((role, index) => ({
...role,
index: index + 1,
columnKey: role.field_key,
}));
this.resourceFields = resourceFields;
this.tableName = tableName;
}
validateFilterRoles() {
const filterFieldsKeys = this.filterRoles.map((r) => r.field_key);
const resourceFieldsKeys = this.resourceFields.map((r) => r.key);
return difference(filterFieldsKeys, resourceFieldsKeys);
}
// @private
buildLogicExpression() {
let expression = '';
this.filterRoles.forEach((role, index) => {
expression += (index === 0)
? `${role.index} ` : `${role.condition} ${role.index} `;
});
return expression.trim();
}
// @public
buildQuery() {
const logicExpression = this.buildLogicExpression();
return (builder) => {
buildFilterQuery(this.tableName, this.filterRoles, logicExpression)(builder);
};
}
}

View File

@@ -5,41 +5,90 @@ import QueryParser from '@/lib/LogicEvaluation/QueryParser';
import resourceFieldsKeys from '@/data/ResourceFieldsKeys'; import resourceFieldsKeys from '@/data/ResourceFieldsKeys';
// const role = { // const role = {
// compatotor: '', // compatotor: String,
// value: '', // value: String,
// columnKey: '', // columnKey: String,
// columnSlug: '', // columnSlug: String,
// index: 1, // index: Number,
// } // }
export function buildRoleQuery(role) { /**
const columnName = resourceFieldsKeys[role.columnKey]; * Get field column metadata and its relation with other tables.
* @param {String} tableName - Table name of target column.
* @param {String} columnKey - Target column key that stored in resource field.
*/
export function getRoleFieldColumn(tableName, columnKey) {
const tableFields = resourceFieldsKeys[tableName];
return (tableFields[columnKey]) ? tableFields[columnKey] : null;
}
/**
* Builds roles queries.
* @param {String} tableName -
* @param {Object} role -
*/
export function buildRoleQuery(tableName, role) {
const fieldRelation = getRoleFieldColumn(tableName, role.columnKey);
const comparatorColumn = fieldRelation.relation || `${tableName}.${fieldRelation.column}`;
console.log(comparatorColumn, role.value);
switch (role.comparator) { switch (role.comparator) {
case 'equals': case 'equals':
default: default:
return (builder) => { return (builder) => {
builder.where(columnName, role.value); builder.where(comparatorColumn, role.value);
}; };
case 'not_equal': case 'not_equal':
case 'not_equals': case 'not_equals':
return (builder) => { return (builder) => {
builder.whereNot(columnName, role.value); builder.whereNot(comparatorColumn, role.value);
};
case 'contain':
return (builder) => {
builder.where(comparatorColumn, 'LIKE', `%${role.value}%`);
}; };
} }
} }
/**
* Extract relation table name from relation.
* @param {String} column -
* @return {String} - join relation table.
*/
export const getTableFromRelationColumn = (column) => {
const splitedColumn = column.split('.');
return (splitedColumn.length > 0) ? splitedColumn[0] : '';
};
/**
* Builds view roles join queries.
* @param {String} tableName -
* @param {Array} roles -
*/
export function buildFilterRolesJoins(tableName, roles) {
return (builder) => {
roles.forEach((role) => {
const fieldColumn = getRoleFieldColumn(tableName, role.columnKey);
if (fieldColumn.relation) {
const joinTable = getTableFromRelationColumn(fieldColumn.relation);
builder.join(joinTable, `${tableName}.${fieldColumn.column}`, '=', fieldColumn.relation);
}
});
};
}
/** /**
* Builds database query from stored view roles. * Builds database query from stored view roles.
* *
* @param {Array} roles - * @param {Array} roles -
* @return {Function} * @return {Function}
*/ */
export function viewRolesBuilder(roles, logicExpression = '') { export function buildFilterRolesQuery(tableName, roles, logicExpression = '') {
const rolesIndexSet = {}; const rolesIndexSet = {};
roles.forEach((role) => { roles.forEach((role) => {
rolesIndexSet[role.index] = buildRoleQuery(role); rolesIndexSet[role.index] = buildRoleQuery(tableName, role);
}); });
// Lexer for logic expression. // Lexer for logic expression.
const lexer = new Lexer(logicExpression); const lexer = new Lexer(logicExpression);
@@ -54,23 +103,36 @@ export function viewRolesBuilder(roles, logicExpression = '') {
} }
/** /**
* Validates the view logic expression. * Builds filter query for query builder.
* @param {String} logicExpression * @param {String} tableName -
* @param {Array} indexes * @param {Array} roles -
* @param {String} logicExpression -
*/ */
export function validateViewLogicExpression(logicExpression, indexes) { export const buildFilterQuery = (tableName, roles, logicExpression) => {
return (builder) => {
buildFilterRolesJoins(tableName, roles)(builder);
buildFilterRolesQuery(tableName, roles, logicExpression)(builder);
};
};
/**
* Validates the view logic expression.
* @param {String} logicExpression -
* @param {Array} indexes -
*/
export function validateFilterLogicExpression(logicExpression, indexes) {
const logicExpIndexes = logicExpression.match(/\d+/g) || []; const logicExpIndexes = logicExpression.match(/\d+/g) || [];
return !difference(logicExpIndexes.map(Number), indexes).length; return !difference(logicExpIndexes.map(Number), indexes).length;
} }
/** /**
* * Validates view roles.
* @param {Array} roles - * @param {Array} roles -
* @param {String} logicExpression - * @param {String} logicExpression -
* @return {Boolean} * @return {Boolean}
*/ */
export function validateViewRoles(roles, logicExpression) { export function validateViewRoles(roles, logicExpression) {
return validateViewLogicExpression(logicExpression, roles.map((r) => r.index)); return validateFilterLogicExpression(logicExpression, roles.map((r) => r.index));
} }
/** /**
@@ -82,7 +144,7 @@ export function mapViewRolesToConditionals(viewRoles) {
return viewRoles.map((viewRole) => ({ return viewRoles.map((viewRole) => ({
comparator: viewRole.comparator, comparator: viewRole.comparator,
value: viewRole.value, value: viewRole.value,
columnKey: viewRole.field.columnKey, columnKey: viewRole.field.key,
slug: viewRole.field.slug, slug: viewRole.field.slug,
index: viewRole.index, index: viewRole.index,
})); }));

View File

@@ -2,8 +2,9 @@
import { Model } from 'objection'; import { Model } from 'objection';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import BaseModel from '@/models/Model'; import BaseModel from '@/models/Model';
import {viewRolesBuilder} from '@/lib/ViewRolesBuilder'; import {
buildFilterQuery,
} from '@/lib/ViewRolesBuilder';
export default class Account extends BaseModel { export default class Account extends BaseModel {
/** /**
* Table name * Table name
@@ -16,19 +17,21 @@ export default class Account extends BaseModel {
* Model modifiers. * Model modifiers.
*/ */
static get modifiers() { static get modifiers() {
const TABLE_NAME = Account.tableName;
return { return {
filterAccounts(query, accountIds) { filterAccounts(query, accountIds) {
if (accountIds.length > 0) { if (accountIds.length > 0) {
query.whereIn('id', accountIds); query.whereIn(`${TABLE_NAME}.id`, accountIds);
} }
}, },
filterAccountTypes(query, typesIds) { filterAccountTypes(query, typesIds) {
if (typesIds.length > 0) { if (typesIds.length > 0) {
query.whereIn('accoun_type_id', typesIds); query.whereIn('account_types.accoun_type_id', typesIds);
} }
}, },
viewRolesBuilder(query, conditionals, expression) { viewRolesBuilder(query, conditionals, expression) {
viewRolesBuilder(conditionals, expression)(query); buildFilterQuery(Account.tableName, conditionals, expression)(query);
}, },
}; };
} }

View File

@@ -3,7 +3,7 @@ import Account from '@/models/Account';
let loginRes; let loginRes;
describe('routes: /accounts/', () => { describe.only('routes: /accounts/', () => {
beforeEach(async () => { beforeEach(async () => {
loginRes = await login(); loginRes = await login();
}); });
@@ -90,8 +90,6 @@ describe('routes: /accounts/', () => {
parent_account_id: account.id, parent_account_id: account.id,
}); });
console.log(res.body);
expect(res.status).equals(200); expect(res.status).equals(200);
}); });
@@ -193,6 +191,7 @@ describe('routes: /accounts/', () => {
}); });
describe('GET: `/accounts`', () => { describe('GET: `/accounts`', () => {
it('Should retrieve accounts resource not found.', async () => { it('Should retrieve accounts resource not found.', async () => {
const res = await request() const res = await request()
.get('/api/accounts') .get('/api/accounts')
@@ -208,7 +207,7 @@ describe('routes: /accounts/', () => {
it('Should retrieve chart of accounts', async () => { it('Should retrieve chart of accounts', async () => {
await create('resource', { name: 'accounts' }); await create('resource', { name: 'accounts' });
const account = await create('account'); const account = await create('account');
const account2 = await create('account', { parent_account_id: account.id }); await create('account', { parent_account_id: account.id });
const res = await request() const res = await request()
.get('/api/accounts') .get('/api/accounts')
@@ -224,7 +223,62 @@ describe('routes: /accounts/', () => {
const accountTypeField = await create('resource_field', { const accountTypeField = await create('resource_field', {
label_name: 'Account type', label_name: 'Account type',
column_key: 'account_type', key: 'type',
resource_id: resource.id,
active: true,
predefined: true,
});
const accountNameField = await create('resource_field', {
label_name: 'Account Name',
key: 'name',
resource_id: resource.id,
active: true,
predefined: true,
});
const accountsView = await create('view', {
name: 'Accounts View',
resource_id: resource.id,
roles_logic_expression: '1 && 2',
});
await create('view_role', {
view_id: accountsView.id,
index: 1,
field_id: accountTypeField.id,
value: '2',
comparator: 'equals',
});
await create('view_role', {
view_id: accountsView.id,
index: 2,
field_id: accountNameField.id,
value: 'account',
comparator: 'contain',
});
await create('account', { name: 'account-1' });
await create('account', { name: 'account-2' });
await create('account', { name: 'account-3', account_type_id: 2 });
const res = await request()
.get('/api/accounts')
.set('x-access-token', loginRes.body.token)
.query({ custom_view_id: accountsView.id })
.send();
expect(res.body.accounts.length).equals(2);
expect(res.body.accounts[0].name).equals('account-2');
expect(res.body.accounts[1].name).equals('account-3');
expect(res.body.accounts[0].account_type_id).equals(2);
expect(res.body.accounts[1].account_type_id).equals(2);
});
it.only('Should retrieve accounts based on view roles conditionals with relation join column.', async () => {
const resource = await create('resource', { name: 'accounts' });
const accountTypeField = await create('resource_field', {
label_name: 'Account type',
key: 'type',
resource_id: resource.id, resource_id: resource.id,
active: true, active: true,
predefined: true, predefined: true,
@@ -249,13 +303,13 @@ describe('routes: /accounts/', () => {
const res = await request() const res = await request()
.get('/api/accounts') .get('/api/accounts')
.set('x-access-token', loginRes.body.token) .set('x-access-token', loginRes.body.token)
.query({ custom_view_id: accountsView.id }) .query({
custom_view_id: accountsView.id
})
.send(); .send();
expect(res.status).equals(200); expect(res.body.accounts.length).equals(1);
res.body.accounts.forEach((account) => { expect(res.body.accounts[0].account_type_id).equals(2);
expect(account).to.deep.include({ accountTypeId: 2 });
});
}); });
it('Should retrieve accounts and child accounts in nested set graph.', async () => { it('Should retrieve accounts and child accounts in nested set graph.', async () => {
@@ -301,7 +355,13 @@ describe('routes: /accounts/', () => {
expect(res.body.accounts[2].name).equals(`${account1.name}${account2.name}${account3.name}`); expect(res.body.accounts[2].name).equals(`${account1.name}${account2.name}${account3.name}`);
}); });
it('Should retrieve filtered accounts according to the given filter roles.', async () => { it('Should retrieve bad request when `filter_roles.*.comparator` not associated to `field_key`.', () => {
});
it('Should retrieve bad request when `filter_roles.*.field_key` not found in accounts resource.', async () => {
const resource = await create('resource', { name: 'accounts' });
const account1 = await create('account', { name: 'ahmed' }); const account1 = await create('account', { name: 'ahmed' });
const account2 = await create('account'); const account2 = await create('account');
const account3 = await create('account'); const account3 = await create('account');
@@ -310,15 +370,163 @@ describe('routes: /accounts/', () => {
.get('/api/accounts') .get('/api/accounts')
.set('x-access-token', loginRes.body.token) .set('x-access-token', loginRes.body.token)
.query({ .query({
filter_roles: [{ stringified_filter_roles: JSON.stringify([{
condition: 'AND',
field_key: 'name', field_key: 'name',
comparator: 'equals', comparator: 'equals',
value: 'ahmed', value: 'ahmed',
}], }, {
condition: 'AND',
field_key: 'name',
comparator: 'equals',
value: 'ahmed',
}]),
});
expect(res.body.errors).include.something.that.deep.equals({
type: 'ACCOUNTS.RESOURCE.HAS.NO.GIVEN.FIELDS', code: 500,
});
});
it('Should retrieve bad request when `filter_roles.*.condition` is invalid.', async () => {
});
it('Should retrieve filtered accounts according to the given account type filter condition.', async () => {
const resource = await create('resource', { name: 'accounts' });
const resourceField = await create('resource_field', {
key: 'type',
resource_id: resource.id,
});
const account1 = await create('account', { name: 'ahmed' });
const account2 = await create('account');
const account3 = await create('account');
const res = await request()
.get('/api/accounts')
.set('x-access-token', loginRes.body.token)
.query({
stringified_filter_roles: JSON.stringify([{
condition: 'AND',
field_key: resourceField.key,
comparator: 'equals',
value: '1',
}]),
}); });
expect(res.body.accounts.length).equals(1); expect(res.body.accounts.length).equals(1);
}); });
it('Shoud retrieve filtered accounts according to the given account description filter condition.', async () => {
const resource = await create('resource', { name: 'accounts' });
const resourceField = await create('resource_field', {
key: 'description',
resource_id: resource.id,
});
const account1 = await create('account', { name: 'ahmed', description: 'here' });
const account2 = await create('account');
const account3 = await create('account');
const res = await request()
.get('/api/accounts')
.set('x-access-token', loginRes.body.token)
.query({
stringified_filter_roles: JSON.stringify([{
condition: 'AND',
field_key: resourceField.key,
comparator: 'contain',
value: 'here',
}]),
});
expect(res.body.accounts.length).equals(1);
expect(res.body.accounts[0].description).equals('here');
});
it('Should retrieve filtered accounts based on given filter roles between OR conditions.', async () => {
const resource = await create('resource', { name: 'accounts' });
const resourceField = await create('resource_field', {
key: 'description',
resource_id: resource.id,
});
const resourceCodeField = await create('resource_field', {
key: 'code',
resource_id: resource.id,
});
const account1 = await create('account', { name: 'ahmed', description: 'target' });
const account2 = await create('account', { description: 'target' });
const account3 = await create('account');
const res = await request()
.get('/api/accounts')
.set('x-access-token', loginRes.body.token)
.query({
stringified_filter_roles: JSON.stringify([{
condition: '&&',
field_key: resourceField.key,
comparator: 'contain',
value: 'target',
}, {
condition: '||',
field_key: resourceCodeField.key,
comparator: 'equals',
value: 'ahmed',
}]),
});
expect(res.body.accounts.length).equals(2);
expect(res.body.accounts[0].description).equals('target');
expect(res.body.accounts[1].description).equals('target');
expect(res.body.accounts[0].name).equals('ahmed');
});
it('Should retrieve filtered accounts from custom view and filter roles.', async () => {
const resource = await create('resource', { name: 'accounts' });
const accountTypeField = await create('resource_field', {
key: 'type', resource_id: resource.id,
});
const accountDescriptionField = await create('resource_field', {
key: 'description', resource_id: resource.id,
});
const account1 = await create('account', { name: 'ahmed-1' });
const account2 = await create('account', { name: 'ahmed-2', account_type_id: 1, description: 'target' });
const account3 = await create('account', { name: 'ahmed-3' });
const accountsView = await create('view', {
name: 'Accounts View',
resource_id: resource.id,
roles_logic_expression: '1',
});
const accountsViewRole = await create('view_role', {
view_id: accountsView.id,
field_id: accountTypeField.id,
index: 1,
value: '1',
comparator: 'equals',
});
const res = await request()
.get('/api/accounts')
.set('x-access-token', loginRes.body.token)
.query({
custom_view_id: accountsView.id,
stringified_filter_roles: JSON.stringify([{
condition: 'AND',
field_key: accountDescriptionField.key,
comparator: 'contain',
value: 'target',
}]),
});
expect(res.body.accounts.length).equals(1);
expect(res.body.accounts[0].name).equals('ahmed-2');
expect(res.body.accounts[0].description).equals('target');
});
}); });
describe('DELETE: `/accounts`', () => { describe('DELETE: `/accounts`', () => {