@bedrockio/model 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.9.0
2
+
3
+ - Added keyword search decomposition.
4
+
5
+ ## 0.8.4
6
+
7
+ - Fixed issues with external models colliding with cache module.
8
+
1
9
  ## 0.8.3
2
10
 
3
11
  - Bumped yada version
package/README.md CHANGED
@@ -679,6 +679,62 @@ a text index applied, then a Mongo text query will be attempted:
679
679
  }
680
680
  ```
681
681
 
682
+ #### Keyword Search Decomposition
683
+
684
+ Mongo text indexes don't allow partial matches by default which can be limiting.
685
+ Field based keyword search can do this but with limitations:
686
+
687
+ ```json
688
+ {
689
+ "attributes": {
690
+ "firstName": "String",
691
+ "lastName": "String"
692
+ },
693
+ "search": {
694
+ "fields": ["firstName", "lastName"]
695
+ }
696
+ }
697
+ ```
698
+
699
+ Although this will perform partial matches for each field, a full name keyword
700
+ like`Frank Reynolds` will not match:
701
+
702
+ ```json
703
+ {
704
+ "$or": [
705
+ {
706
+ "firstName": {
707
+ "$regex": "Frank Reynolds"
708
+ }
709
+ },
710
+ {
711
+ "lastName": {
712
+ "$regex": "Frank Reynolds"
713
+ }
714
+ }
715
+ ]
716
+ }
717
+ ```
718
+
719
+ Field decomposition provides a hint to decompose the keyword to provide matches:
720
+
721
+ ```json
722
+ {
723
+ "attributes": {
724
+ "firstName": "String",
725
+ "lastName": "String"
726
+ },
727
+ "search": {
728
+ "decompose": "{firstName} {lastName...}",
729
+ "fields": ["firstName", "lastName"]
730
+ }
731
+ }
732
+ ```
733
+
734
+ This tells the keyword query builder that the first token should be taken as the
735
+ `firstName` and any tokens after that (`...`) should be taken as the last. Note
736
+ the `decompose` field may also be an array.
737
+
682
738
  #### Search Validation
683
739
 
684
740
  The [validation](#validation) generated for search using `getSearchValidation`
package/dist/cjs/cache.js CHANGED
@@ -142,6 +142,9 @@ function compileSyncOperations() {
142
142
  continue;
143
143
  }
144
144
  const definition = definitionMap.get(schema);
145
+ if (!definition) {
146
+ continue;
147
+ }
145
148
  const fields = resolveCachedFields(schema, definition);
146
149
  for (let [ref, group] of Object.entries((0, _lodash.groupBy)(fields, 'ref'))) {
147
150
  const hasSynced = group.some(entry => {
@@ -23,13 +23,12 @@ function applySearch(schema, definition) {
23
23
  validateDefinition(definition);
24
24
  validateSearchFields(schema, definition);
25
25
  const {
26
- query: searchQuery,
27
- fields: searchFields
28
- } = definition.search || {};
26
+ search: config = {}
27
+ } = definition;
29
28
  schema.static('search', function search(body = {}) {
30
29
  const options = {
31
30
  ..._const.SEARCH_DEFAULTS,
32
- ...searchQuery,
31
+ ...config.query,
33
32
  ...body
34
33
  };
35
34
  const {
@@ -50,7 +49,7 @@ function applySearch(schema, definition) {
50
49
  };
51
50
  }
52
51
  if (keyword) {
53
- const keywordQuery = buildKeywordQuery(schema, keyword, searchFields);
52
+ const keywordQuery = buildKeywordQuery(schema, keyword, config);
54
53
  query = (0, _query.mergeQuery)(query, keywordQuery);
55
54
  }
56
55
  if (_env.debug) {
@@ -159,39 +158,97 @@ function resolveSort(sort, schema) {
159
158
  // https://stackoverflow.com/questions/44833817/mongodb-full-and-partial-text-search
160
159
  // https://jira.mongodb.org/browse/SERVER-15090
161
160
 
162
- function buildKeywordQuery(schema, keyword, fields) {
163
- let queries;
161
+ function buildKeywordQuery(schema, keyword, config) {
162
+ if (hasTextIndex(schema)) {
163
+ _logger.default.debug('Using text index for keyword search.');
164
+ return getTextIndexQuery(keyword);
165
+ }
166
+ keyword = (0, _lodash.escapeRegExp)(keyword);
167
+ const queries = [...getDecomposedQueries(keyword, config), ...getFieldQueries(keyword, config)];
164
168
 
165
- // Prefer defined search fields over
166
- // text indexes to perform keyword search.
167
- if (fields) {
168
- queries = buildRegexQuery(keyword, fields);
169
- } else if (hasTextIndex(schema)) {
170
- queries = [getTextQuery(keyword)];
169
+ // Note: Mongo will error on empty $or/$and array.
170
+ if (queries.length > 1) {
171
+ return {
172
+ $or: queries
173
+ };
174
+ } else if (queries.length) {
175
+ return queries[0];
171
176
  } else {
172
- throw new Error('No keyword fields defined.');
177
+ _logger.default.debug('Could not find search fields on the model.');
178
+ throw new Error('Could not compose keyword query.');
173
179
  }
174
- if (ObjectId.isValid(keyword)) {
175
- queries.push({
176
- _id: keyword
177
- });
180
+ }
181
+ function getTextIndexQuery(keyword) {
182
+ return {
183
+ $text: {
184
+ $search: keyword
185
+ }
186
+ };
187
+ }
188
+ function getDecomposedQueries(keyword, config) {
189
+ const {
190
+ decompose
191
+ } = config;
192
+ if (!decompose) {
193
+ return [];
178
194
  }
179
-
180
- // Note: Mongo will error on empty $or/$and array.
181
- return queries.length ? {
182
- $or: queries
183
- } : {};
195
+ const decomposers = compileDecomposers(decompose);
196
+ return decomposers.map(decomposer => {
197
+ return decomposer(keyword);
198
+ }).filter(Boolean);
199
+ }
200
+ function compileDecomposers(arg) {
201
+ const arr = Array.isArray(arg) ? arg : [arg];
202
+ return arr.map(compileDecomposer);
184
203
  }
185
- function buildRegexQuery(keyword, fields) {
186
- return fields.map(field => {
187
- const regexKeyword = (0, _lodash.escapeRegExp)(keyword);
204
+ const DECOMPOSE_TEMPLATE_REG = /{(\w+)(\.\.\.)?}/g;
205
+ const compileDecomposer = (0, _lodash.memoize)(template => {
206
+ if (!template.match(DECOMPOSE_TEMPLATE_REG)) {
207
+ throw new Error(`Could not compile decompose template ${template}.`);
208
+ }
209
+ const fields = [];
210
+ let src = template;
211
+ src = src.replace(DECOMPOSE_TEMPLATE_REG, (_, field, rest) => {
212
+ fields.push(field);
213
+ return rest ? '(.+)' : '(\\S+)';
214
+ });
215
+ src = src.replace(/\s+/, '\\s+');
216
+ const reg = RegExp(src);
217
+ return keyword => {
218
+ const match = keyword.match(reg);
219
+ if (match) {
220
+ const query = {};
221
+ fields.forEach((field, i) => {
222
+ query[field] = {
223
+ $regex: match[i + 1],
224
+ $options: 'i'
225
+ };
226
+ });
227
+ return query;
228
+ }
229
+ };
230
+ });
231
+ function getFieldQueries(keyword, config) {
232
+ const {
233
+ fields
234
+ } = config;
235
+ if (!fields) {
236
+ return [];
237
+ }
238
+ const queries = fields.map(field => {
188
239
  return {
189
240
  [field]: {
190
- $regex: regexKeyword,
241
+ $regex: keyword,
191
242
  $options: 'i'
192
243
  }
193
244
  };
194
245
  });
246
+ if (ObjectId.isValid(keyword)) {
247
+ queries.push({
248
+ _id: keyword
249
+ });
250
+ }
251
+ return queries;
195
252
  }
196
253
  function hasTextIndex(schema) {
197
254
  return schema.indexes().some(([spec]) => {
@@ -200,13 +257,6 @@ function hasTextIndex(schema) {
200
257
  });
201
258
  });
202
259
  }
203
- function getTextQuery(keyword) {
204
- return {
205
- $text: {
206
- $search: keyword
207
- }
208
- };
209
- }
210
260
 
211
261
  // Normalizes mongo queries. Flattens plain nested paths
212
262
  // to dot syntax while preserving mongo operators and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bedrockio/model",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Bedrock utilities for model creation.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/cache.js CHANGED
@@ -144,7 +144,13 @@ function compileSyncOperations() {
144
144
  // Model has already been compiled so skip.
145
145
  continue;
146
146
  }
147
+
147
148
  const definition = definitionMap.get(schema);
149
+
150
+ if (!definition) {
151
+ continue;
152
+ }
153
+
148
154
  const fields = resolveCachedFields(schema, definition);
149
155
 
150
156
  for (let [ref, group] of Object.entries(groupBy(fields, 'ref'))) {
package/src/search.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import yd from '@bedrockio/yada';
2
2
  import logger from '@bedrockio/logger';
3
3
  import mongoose from 'mongoose';
4
- import { pick, isEmpty, escapeRegExp, isPlainObject } from 'lodash';
4
+ import { pick, isEmpty, memoize, escapeRegExp, isPlainObject } from 'lodash';
5
5
 
6
6
  import {
7
7
  getField,
@@ -24,12 +24,12 @@ export function applySearch(schema, definition) {
24
24
  validateDefinition(definition);
25
25
  validateSearchFields(schema, definition);
26
26
 
27
- const { query: searchQuery, fields: searchFields } = definition.search || {};
27
+ const { search: config = {} } = definition;
28
28
 
29
29
  schema.static('search', function search(body = {}) {
30
30
  const options = {
31
31
  ...SEARCH_DEFAULTS,
32
- ...searchQuery,
32
+ ...config.query,
33
33
  ...body,
34
34
  };
35
35
 
@@ -45,14 +45,14 @@ export function applySearch(schema, definition) {
45
45
  }
46
46
 
47
47
  if (keyword) {
48
- const keywordQuery = buildKeywordQuery(schema, keyword, searchFields);
48
+ const keywordQuery = buildKeywordQuery(schema, keyword, config);
49
49
  query = mergeQuery(query, keywordQuery);
50
50
  }
51
51
 
52
52
  if (debug) {
53
53
  logger.info(
54
54
  `Search query for ${this.modelName}:\n`,
55
- JSON.stringify(query, null, 2)
55
+ JSON.stringify(query, null, 2),
56
56
  );
57
57
  }
58
58
 
@@ -121,7 +121,7 @@ function validateDefinition(definition) {
121
121
  [
122
122
  '"search" field on model definition must not be an array.',
123
123
  'Use "search.fields" to define fields for keyword queries.',
124
- ].join('\n')
124
+ ].join('\n'),
125
125
  );
126
126
  throw new Error('Invalid model definition.');
127
127
  }
@@ -174,37 +174,113 @@ function resolveSort(sort, schema) {
174
174
  // https://stackoverflow.com/questions/44833817/mongodb-full-and-partial-text-search
175
175
  // https://jira.mongodb.org/browse/SERVER-15090
176
176
 
177
- function buildKeywordQuery(schema, keyword, fields) {
178
- let queries;
177
+ function buildKeywordQuery(schema, keyword, config) {
178
+ if (hasTextIndex(schema)) {
179
+ logger.debug('Using text index for keyword search.');
180
+ return getTextIndexQuery(keyword);
181
+ }
182
+
183
+ keyword = escapeRegExp(keyword);
184
+
185
+ const queries = [
186
+ ...getDecomposedQueries(keyword, config),
187
+ ...getFieldQueries(keyword, config),
188
+ ];
179
189
 
180
- // Prefer defined search fields over
181
- // text indexes to perform keyword search.
182
- if (fields) {
183
- queries = buildRegexQuery(keyword, fields);
184
- } else if (hasTextIndex(schema)) {
185
- queries = [getTextQuery(keyword)];
190
+ // Note: Mongo will error on empty $or/$and array.
191
+ if (queries.length > 1) {
192
+ return {
193
+ $or: queries,
194
+ };
195
+ } else if (queries.length) {
196
+ return queries[0];
186
197
  } else {
187
- throw new Error('No keyword fields defined.');
198
+ logger.debug('Could not find search fields on the model.');
199
+ throw new Error('Could not compose keyword query.');
188
200
  }
201
+ }
189
202
 
190
- if (ObjectId.isValid(keyword)) {
191
- queries.push({ _id: keyword });
203
+ function getTextIndexQuery(keyword) {
204
+ return {
205
+ $text: {
206
+ $search: keyword,
207
+ },
208
+ };
209
+ }
210
+
211
+ function getDecomposedQueries(keyword, config) {
212
+ const { decompose } = config;
213
+ if (!decompose) {
214
+ return [];
192
215
  }
193
216
 
194
- // Note: Mongo will error on empty $or/$and array.
195
- return queries.length ? { $or: queries } : {};
217
+ const decomposers = compileDecomposers(decompose);
218
+
219
+ return decomposers
220
+ .map((decomposer) => {
221
+ return decomposer(keyword);
222
+ })
223
+ .filter(Boolean);
224
+ }
225
+
226
+ function compileDecomposers(arg) {
227
+ const arr = Array.isArray(arg) ? arg : [arg];
228
+ return arr.map(compileDecomposer);
196
229
  }
197
230
 
198
- function buildRegexQuery(keyword, fields) {
199
- return fields.map((field) => {
200
- const regexKeyword = escapeRegExp(keyword);
231
+ const DECOMPOSE_TEMPLATE_REG = /{(\w+)(\.\.\.)?}/g;
232
+
233
+ const compileDecomposer = memoize((template) => {
234
+ if (!template.match(DECOMPOSE_TEMPLATE_REG)) {
235
+ throw new Error(`Could not compile decompose template ${template}.`);
236
+ }
237
+
238
+ const fields = [];
239
+
240
+ let src = template;
241
+ src = src.replace(DECOMPOSE_TEMPLATE_REG, (_, field, rest) => {
242
+ fields.push(field);
243
+ return rest ? '(.+)' : '(\\S+)';
244
+ });
245
+ src = src.replace(/\s+/, '\\s+');
246
+ const reg = RegExp(src);
247
+
248
+ return (keyword) => {
249
+ const match = keyword.match(reg);
250
+ if (match) {
251
+ const query = {};
252
+ fields.forEach((field, i) => {
253
+ query[field] = {
254
+ $regex: match[i + 1],
255
+ $options: 'i',
256
+ };
257
+ });
258
+ return query;
259
+ }
260
+ };
261
+ });
262
+
263
+ function getFieldQueries(keyword, config) {
264
+ const { fields } = config;
265
+
266
+ if (!fields) {
267
+ return [];
268
+ }
269
+
270
+ const queries = fields.map((field) => {
201
271
  return {
202
272
  [field]: {
203
- $regex: regexKeyword,
273
+ $regex: keyword,
204
274
  $options: 'i',
205
275
  },
206
276
  };
207
277
  });
278
+
279
+ if (ObjectId.isValid(keyword)) {
280
+ queries.push({ _id: keyword });
281
+ }
282
+
283
+ return queries;
208
284
  }
209
285
 
210
286
  function hasTextIndex(schema) {
@@ -215,14 +291,6 @@ function hasTextIndex(schema) {
215
291
  });
216
292
  }
217
293
 
218
- function getTextQuery(keyword) {
219
- return {
220
- $text: {
221
- $search: keyword,
222
- },
223
- };
224
- }
225
-
226
294
  // Normalizes mongo queries. Flattens plain nested paths
227
295
  // to dot syntax while preserving mongo operators and
228
296
  // handling specialed query syntax:
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"AAsBA,gEAwDC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eASsB,CAAA;;;;;;;;;;;;;;;;;EAgBrB"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"AAsBA,gEAwDC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAWS,CAAA;;;;;;;;;;;;;;;;;EAcR"}