Skip to content

Commit 6a39f05

Browse files
fix: allow injection of relations when using smartfield' search feature (#735)
1 parent fb58717 commit 6a39f05

File tree

3 files changed

+222
-16
lines changed

3 files changed

+222
-16
lines changed

src/services/query-options.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ class QueryOptions {
5757
/** Compute includes for sequelizeOptions getter. */
5858
get _sequelizeInclude() {
5959
const fields = [...this._requestedFields, ...this._neededFields];
60-
const include = new QueryBuilder().getIncludes(this._model, fields.length ? fields : null);
60+
const include = [
61+
...new QueryBuilder().getIncludes(this._model, fields.length ? fields : null),
62+
...this._customerIncludes,
63+
];
64+
6165
return include.length ? include : null;
6266
}
6367

@@ -85,6 +89,7 @@ class QueryOptions {
8589
this._order = [];
8690
this._offset = undefined;
8791
this._limit = undefined;
92+
this._customerIncludes = [];
8893

8994
if (this._options.includeRelations) {
9095
_.values(this._model.associations)
@@ -141,13 +146,17 @@ class QueryOptions {
141146
const fieldNames = this._requestedFields.size ? [...this._requestedFields] : null;
142147
const helper = new SearchBuilder(this._model, options, { search, searchExtended }, fieldNames);
143148

144-
const conditions = helper.performWithSmartFields(this._options.tableAlias);
149+
const { conditions, include } = helper.performWithSmartFields(this._options.tableAlias);
145150
if (conditions) {
146151
this._where.push(conditions);
147152
} else {
148153
this._where.push(this._Sequelize.literal('(0=1)'));
149154
}
150155

156+
if (include) {
157+
this._customerIncludes.push(...include);
158+
}
159+
151160
return helper.getFieldsSearched();
152161
}
153162

src/services/search-builder.js

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,25 +71,28 @@ function SearchBuilder(model, opts, params, fieldNamesRequested) {
7171
this.hasExtendedSearchConditions = () => hasExtendedConditions;
7272

7373
this.performWithSmartFields = (associationName) => {
74-
const { search } = params;
75-
76-
const where = this.perform(associationName);
77-
if (!where[OPERATORS.OR]) {
78-
where[OPERATORS.OR] = [];
79-
}
74+
// Retrocompatibility: customers which implement search on smart fields are expected to
75+
// inject their conditions at .where[Op.and][0][Op.or].push(searchCondition)
76+
// https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
77+
const query = {
78+
include: [],
79+
where: { [OPERATORS.AND]: [this.perform(associationName)] },
80+
};
8081

8182
schema.fields.filter((field) => field.search).forEach((field) => {
8283
try {
83-
// Retrocompatibility: customers which implement search on smart fields are expected to
84-
// inject their conditions at .where[Op.and][0][Op.or].push(searchCondition)
85-
// https://docs.forestadmin.com/documentation/reference-guide/fields/create-and-manage-smart-fields
86-
field.search({ where: { [OPERATORS.AND]: [where] } }, search);
84+
field.search(query, params.search);
8785
} catch (error) {
8886
Interface.logger.error(`Cannot search properly on Smart Field ${field.field}`, error);
8987
}
9088
});
9189

92-
return where[OPERATORS.OR].length ? where : null;
90+
return {
91+
include: query.include,
92+
conditions: query.where[OPERATORS.AND][0][OPERATORS.OR].length
93+
? query.where[OPERATORS.AND][0]
94+
: null,
95+
};
9396
};
9497

9598
this.perform = (associationName) => {
@@ -100,7 +103,6 @@ function SearchBuilder(model, opts, params, fieldNamesRequested) {
100103
}
101104

102105
const aliasName = associationName || schema.name;
103-
const where = {};
104106
const or = [];
105107

106108
function pushCondition(condition, fieldName) {
@@ -255,8 +257,7 @@ function SearchBuilder(model, opts, params, fieldNamesRequested) {
255257
});
256258
}
257259

258-
if (or.length) { where[OPERATORS.OR] = or; }
259-
return where;
260+
return { [OPERATORS.OR]: or };
260261
};
261262
}
262263

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
const Interface = require('forest-express');
2+
const { STRING, INTEGER } = require('sequelize');
3+
const { Op } = require('sequelize');
4+
const databases = require('../databases');
5+
const runWithConnection = require('../helpers/run-with-connection');
6+
const ResourcesGetter = require('../../src/services/resources-getter');
7+
8+
function rot13(s) {
9+
return s.replace(/[A-Z]/gi, (c) =>
10+
'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm'[
11+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.indexOf(c)]);
12+
}
13+
14+
async function setup(sequelize) {
15+
Interface.Schemas = { schemas: {} };
16+
17+
// Shelves
18+
const Shelves = sequelize.define('shelves', {
19+
id: { type: INTEGER, primaryKey: true },
20+
floor: { field: 'floor', type: INTEGER },
21+
}, { tableName: 'shelves', timestamps: false });
22+
23+
Interface.Schemas.schemas.shelves = {
24+
name: 'shelves',
25+
idField: 'id',
26+
primaryKeys: ['id'],
27+
isCompositePrimary: false,
28+
fields: [
29+
{ field: 'id', type: 'Number' },
30+
{ field: 'floor', columnName: 'floor', type: 'Number' },
31+
],
32+
};
33+
34+
// Books
35+
const Books = sequelize.define('books', {
36+
id: { type: INTEGER, primaryKey: true },
37+
title: { field: 'title', type: STRING },
38+
}, { tableName: 'books', timestamps: false });
39+
40+
Interface.Schemas.schemas.books = {
41+
name: 'books',
42+
idField: 'id',
43+
primaryKeys: ['id'],
44+
isCompositePrimary: false,
45+
fields: [
46+
{ field: 'id', type: 'Number' },
47+
{ field: 'title', columnName: 'title', type: 'String' },
48+
{
49+
field: 'encrypted',
50+
isVirtual: true,
51+
type: 'String',
52+
get: (record) => rot13(record.title),
53+
search: (query, search) => {
54+
query.where[Op.and][0][Op.or].push({
55+
title: rot13(search),
56+
});
57+
},
58+
},
59+
],
60+
};
61+
62+
// Reviews
63+
const Reviews = sequelize.define('reviews', {
64+
id: { type: INTEGER, primaryKey: true },
65+
content: { field: 'content', type: STRING },
66+
}, { tableName: 'reviews', timestamps: false });
67+
68+
Interface.Schemas.schemas.reviews = {
69+
name: 'reviews',
70+
idField: 'id',
71+
primaryKeys: ['id'],
72+
isCompositePrimary: false,
73+
fields: [
74+
{ field: 'id', type: 'Number' },
75+
{ field: 'content', columnName: 'content', type: 'String' },
76+
{
77+
field: 'floor',
78+
isVirtual: true,
79+
type: 'String',
80+
get: () => null,
81+
search: (query, search) => {
82+
query.include.push({
83+
association: 'book',
84+
include: [{ association: 'shelve' }],
85+
});
86+
87+
query.where[Op.and][0][Op.or].push({
88+
'$book.shelve.floor$': Number.parseInt(search, 10),
89+
});
90+
},
91+
},
92+
],
93+
};
94+
95+
// Relations
96+
97+
Books.belongsTo(Shelves, {
98+
foreignKey: { name: 'shelveId' },
99+
as: 'shelve',
100+
});
101+
102+
Reviews.belongsTo(Books, {
103+
foreignKey: { name: 'bookId' },
104+
as: 'book',
105+
});
106+
107+
108+
await sequelize.sync({ force: true });
109+
110+
await Shelves.create({ id: 1, floor: 666 });
111+
await Shelves.create({ id: 2, floor: 667 });
112+
113+
await Books.create({ id: 1, shelveId: 1, title: 'nowhere' });
114+
115+
await Reviews.create({ id: 1, bookId: 1, content: 'abc' });
116+
117+
return { Shelves, Books, Reviews };
118+
}
119+
120+
describe('integration > Smart field', () => {
121+
Object.values(databases).forEach((connectionManager) => {
122+
describe(`dialect ${connectionManager.getDialect()}`, () => {
123+
it('should not find books matching the encrypted field', async () => {
124+
expect.assertions(1);
125+
126+
await runWithConnection(connectionManager, async (sequelize) => {
127+
const { Books } = await setup(sequelize);
128+
const params = {
129+
fields: { books: 'id,title,encrypted' },
130+
sort: 'id',
131+
page: { number: '1', size: '30' },
132+
timezone: 'Europe/Paris',
133+
search: 'hello',
134+
};
135+
136+
const count = await new ResourcesGetter(Books, null, params).count();
137+
expect(count).toStrictEqual(0);
138+
});
139+
});
140+
141+
it('should find books matching the encrypted field', async () => {
142+
expect.assertions(1);
143+
144+
await runWithConnection(connectionManager, async (sequelize) => {
145+
const { Books } = await setup(sequelize);
146+
const params = {
147+
fields: { books: 'id,title,encrypted' },
148+
sort: 'id',
149+
page: { number: '1', size: '30' },
150+
timezone: 'Europe/Paris',
151+
search: 'abjurer',
152+
};
153+
154+
const count = await new ResourcesGetter(Books, null, params).count();
155+
expect(count).toStrictEqual(1);
156+
});
157+
});
158+
159+
it('should not find reviews on the floor 500', async () => {
160+
expect.assertions(1);
161+
162+
await runWithConnection(connectionManager, async (sequelize) => {
163+
const { Reviews } = await setup(sequelize);
164+
const params = {
165+
fields: { books: 'id,title,encrypted' },
166+
sort: 'id',
167+
page: { number: '1', size: '30' },
168+
timezone: 'Europe/Paris',
169+
search: '500',
170+
};
171+
172+
const count = await new ResourcesGetter(Reviews, null, params).count();
173+
expect(count).toStrictEqual(0);
174+
});
175+
});
176+
177+
it('should find reviews on the floor 666', async () => {
178+
expect.assertions(1);
179+
180+
await runWithConnection(connectionManager, async (sequelize) => {
181+
const { Reviews } = await setup(sequelize);
182+
const params = {
183+
fields: { books: 'id,title,encrypted' },
184+
sort: 'id',
185+
page: { number: '1', size: '30' },
186+
timezone: 'Europe/Paris',
187+
search: '666',
188+
};
189+
190+
const count = await new ResourcesGetter(Reviews, null, params).count();
191+
expect(count).toStrictEqual(1);
192+
});
193+
});
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)