Skip to content

Commit

Permalink
feat: add support for belongsTo and hasOne filters on related data (#715
Browse files Browse the repository at this point in the history
)
  • Loading branch information
romain-gilliotte authored May 6, 2021
1 parent 0d1106a commit 2bc769e
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 62 deletions.
7 changes: 5 additions & 2 deletions src/services/has-many-getter.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Operators from '../utils/operators';
import QueryUtils from '../utils/query';
import PrimaryKeysManager from './primary-keys-manager';
import ResourcesGetter from './resources-getter';

Expand All @@ -20,12 +22,13 @@ class HasManyGetter extends ResourcesGetter {
}

async _buildQueryOptions(buildOptions = {}) {
const operators = Operators.getInstance({ Sequelize: this._parentModel.sequelize.constructor });
const { associationName, recordId } = this._params;
const [model, options] = await super._buildQueryOptions({
...buildOptions, tableAlias: associationName,
});

const parentOptions = {
const parentOptions = QueryUtils.bubbleWheresInPlace(operators, {
where: new PrimaryKeysManager(this._parentModel).getRecordsConditions([recordId]),
include: [{
model,
Expand All @@ -35,7 +38,7 @@ class HasManyGetter extends ResourcesGetter {
where: options.where,
include: options.include,
}],
};
});

if (!buildOptions.forCount) {
parentOptions.subQuery = false; // Why?
Expand Down
5 changes: 3 additions & 2 deletions src/services/query-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import LiveQueryChecker from './live-query-checker';
import PrimaryKeysManager from './primary-keys-manager';
import QueryBuilder from './query-builder';
import SearchBuilder from './search-builder';
import QueryUtils from '../utils/query';

/**
* Sequelize query options generator which is configured using forest admin concepts (filters,
Expand Down Expand Up @@ -41,15 +42,15 @@ class QueryOptions {

/** Compute sequelize where condition for sequelizeOptions getter. */
get _sequelizeWhere() {
const { AND } = Operators.getInstance({ Sequelize: this._Sequelize });
const operators = Operators.getInstance({ Sequelize: this._Sequelize });

switch (this._where.length) {
case 0:
return null;
case 1:
return this._where[0];
default:
return { [AND]: this._where };
return QueryUtils.mergeWhere(operators, ...this._where);
}
}

Expand Down
44 changes: 44 additions & 0 deletions src/utils/object-tools.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import _ from 'lodash';

/** Do object1 and object2 have at least one common key or Symbol? */
exports.plainObjectsShareNoKeys = (object1, object2) => {
if (!_.isPlainObject(object1) || !_.isPlainObject(object2)) {
return false;
}

const keys1 = [...Object.getOwnPropertyNames(object1), ...Object.getOwnPropertySymbols(object1)];
const keys2 = [...Object.getOwnPropertyNames(object2), ...Object.getOwnPropertySymbols(object2)];
const commonKeys = keys1.filter((key) => keys2.includes(key));

return commonKeys.length === 0;
};

/**
* Clone object recursively while rewriting keys with the callback function.
* Symbols are copied without modification (Sequelize.Ops are javascript symbols).
*
* @example
* mapKeysDeep({a: {b: 1}}, key => `_${key}_`);
* => {_a_: {_b_: 1}}
*/
exports.mapKeysDeep = (object, callback) => {
if (Array.isArray(object)) {
return object.map((child) => exports.mapKeysDeep(child, callback));
}

if (_.isPlainObject(object)) {
const newObject = {};

Object.getOwnPropertyNames(object).forEach((name) => {
newObject[callback(name)] = exports.mapKeysDeep(object[name], callback);
});

Object.getOwnPropertySymbols(object).forEach((symbol) => {
newObject[symbol] = exports.mapKeysDeep(object[symbol], callback);
});

return newObject;
}

return object;
};
47 changes: 44 additions & 3 deletions src/utils/query.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Orm from './orm';
import ObjectTools from './object-tools';

exports.getReferenceSchema = (schemas, modelSchema, associationName) => {
const schemaField = modelSchema.fields.find((field) => field.field === associationName);
Expand All @@ -11,15 +12,55 @@ exports.getReferenceSchema = (schemas, modelSchema, associationName) => {
};

exports.getReferenceField = (schemas, modelSchema, associationName, fieldName) => {
function getDefaultValue() { return `${associationName}.${fieldName}`; }

const associationSchema = exports.getReferenceSchema(
schemas, modelSchema, associationName, fieldName,
);

// NOTICE: No association schema found, no name transformation tried.
if (!associationSchema) { return getDefaultValue(); }
if (!associationSchema) { return `${associationName}.${fieldName}`; }

const belongsToColumnName = Orm.getColumnName(associationSchema, fieldName);
return `${associationName}.${belongsToColumnName}`;
};

/**
* When they don't have common keys, merge objects together.
* This is used to avoid having too many nested 'AND' conditions on sequelize queries, which
* makes debugging and testing more painful than it could be.
*/
exports.mergeWhere = (operators, ...wheres) => wheres
.filter(Boolean)
.reduce((where1, where2) => (ObjectTools.plainObjectsShareNoKeys(where1, where2)
? { ...where1, ...where2 }
: { [operators.AND]: [where1, where2] }));

/**
* Extract all where conditions along the include tree, and bubbles them up to the top.
* This allows to work around a sequelize quirk that cause nested 'where' to fail when they
* refer to relation fields from an intermediary include (ie '$book.id$').
*
* This happens when forest admin filters on relations are used.
*
* @see https://sequelize.org/master/manual/eager-loading.html#complex-where-clauses-at-the-top-level
* @see https://github.com/ForestAdmin/forest-express-sequelize/blob/7d7ad0/src/services/filters-parser.js#L104
*/
exports.bubbleWheresInPlace = (operators, options) => {
const parentInclude = options.include ?? [];

parentInclude.forEach((include) => {
exports.bubbleWheresInPlace(operators, include);

if (include.where) {
const newWhere = ObjectTools.mapKeysDeep(include.where, (key) => (
key[0] === '$' && key[key.length - 1] === '$'
? `$${include.as}.${key.substring(1)}`
: `$${include.as}.${key}$`
));

options.where = exports.mergeWhere(operators, options.where, newWhere);
delete include.where;
}
});

return options;
};
78 changes: 38 additions & 40 deletions test/services/has-many-getter.test.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import Sequelize from 'sequelize';
import Interface from 'forest-express';
import Sequelize from 'sequelize';
import HasManyGetter from '../../src/services/has-many-getter';
import Operators from '../../src/utils/operators';
import { sequelizePostgres } from '../databases';

describe('services > HasManyGetter', () => {
const sequelizeOptions = {
const lianaOptions = {
sequelize: Sequelize,
Sequelize,
connections: { sequelize: sequelizePostgres.createConnection() },
};
const { AND, OR, GT } = Operators.getInstance(sequelizeOptions);
const { OR, GT } = Operators.getInstance(lianaOptions);
const timezone = 'Europe/Paris';
const baseParams = { timezone, associationName: 'users', recordId: 1 };

describe('_buildQueryOptions', () => {
const options = { tableAlias: 'users' };
Expand Down Expand Up @@ -39,42 +40,41 @@ describe('services > HasManyGetter', () => {
it('should build an empty where condition', async () => {
expect.assertions(1);

const hasManyGetter = new HasManyGetter(model, association, sequelizeOptions, {
timezone, recordId: 1, associationName: 'users',
});
const hasManyGetter = new HasManyGetter(model, association, lianaOptions, baseParams);
const queryOptions = await hasManyGetter._buildQueryOptions(options);
expect(queryOptions.include[0].where).toBeUndefined();

expect(queryOptions.where).toStrictEqual({ id: 1 });
});
});

describe('with filters in params', () => {
it('should build a where condition containing the provided filters formatted', async () => {
expect.assertions(1);
const filters = '{ "field": "id", "operator": "greater_than", "value": 1 }';

const hasManyGetter = new HasManyGetter(
model, association, sequelizeOptions, {
filters, timezone, recordId: 1, associationName: 'users',
},
);
const params = {
...baseParams,
filters: '{ "field": "id", "operator": "greater_than", "value": 1 }',
};
const hasManyGetter = new HasManyGetter(model, association, lianaOptions, params);
const queryOptions = await hasManyGetter._buildQueryOptions(options);
expect(queryOptions.include[0].where).toStrictEqual({ id: { [GT]: 1 } });

expect(queryOptions.where).toStrictEqual({
id: 1,
'$users.id$': { [GT]: 1 },
});
});
});

describe('with search in params', () => {
it('should build a where condition containing the provided search', async () => {
expect.assertions(1);
const search = 'test';

const hasManyGetter = new HasManyGetter(
model, association, sequelizeOptions, {
search, timezone, recordId: 1, associationName: 'users',
},
);
const params = { ...baseParams, search: 'test' };
const hasManyGetter = new HasManyGetter(model, association, lianaOptions, params);
const queryOptions = await hasManyGetter._buildQueryOptions(options);

expect(queryOptions.include[0].where).toStrictEqual({
expect(queryOptions.where).toStrictEqual({
id: 1,
[OR]: expect.arrayContaining([
expect.objectContaining({
attribute: { args: [{ col: 'users.name' }], fn: 'lower' },
Expand All @@ -89,27 +89,25 @@ describe('services > HasManyGetter', () => {
describe('with filters and search in params', () => {
it('should build a where condition containing the provided filters and search', async () => {
expect.assertions(1);
const filters = '{ "field": "id", "operator": "greater_than", "value": 1 }';
const search = 'test';

const hasManyGetter = new HasManyGetter(
model, association, sequelizeOptions, {
filters, search, timezone, recordId: 1, associationName: 'users',
},
);
const params = {
...baseParams,
filters: '{ "field": "id", "operator": "greater_than", "value": 1 }',
search: 'test',
};
const hasManyGetter = new HasManyGetter(model, association, lianaOptions, params);
const queryOptions = await hasManyGetter._buildQueryOptions(options);
expect(queryOptions.include[0].where).toStrictEqual({
[AND]: [{
[OR]: expect.arrayContaining([
expect.objectContaining({
attribute: { args: [{ col: 'users.name' }], fn: 'lower' },
comparator: ' LIKE ',
logic: { args: ['%test%'], fn: 'lower' },
}),
]),
}, {
id: { [GT]: 1 },
}],

expect(queryOptions.where).toStrictEqual({
id: 1,
'$users.id$': { [GT]: 1 },
[OR]: expect.arrayContaining([
expect.objectContaining({
attribute: { args: [{ col: 'users.name' }], fn: 'lower' },
comparator: ' LIKE ',
logic: { args: ['%test%'], fn: 'lower' },
}),
]),
});
});
});
Expand Down
Loading

0 comments on commit 2bc769e

Please sign in to comment.