UNPKG

16.9 kBJavaScriptView Raw
1// Copyright IBM Corp. 2018,2019. All Rights Reserved.
2// Node module: loopback-datasource-juggler
3// This file is licensed under the MIT License.
4// License text available at https://opensource.org/licenses/MIT
5
6// Turning on strict for this file breaks lots of test cases;
7// disabling strict for this file
8/* eslint-disable strict */
9
10module.exports = ModelUtils;
11
12/*!
13 * Module dependencies
14 */
15const g = require('strong-globalize')();
16const geo = require('./geo');
17const {
18 fieldsToArray,
19 sanitizeQuery: sanitizeQueryOrData,
20 isPlainObject,
21 isClass,
22 toRegExp,
23} = require('./utils');
24const BaseModel = require('./model');
25
26/**
27 * A mixin to contain utility methods for DataAccessObject
28 */
29function ModelUtils() {
30}
31
32/**
33 * Verify if allowExtendedOperators is enabled
34 * @options {Object} [options] Optional options to use.
35 * @property {Boolean} allowExtendedOperators.
36 * @returns {Boolean} Returns `true` if allowExtendedOperators is enabled, else `false`.
37 */
38ModelUtils._allowExtendedOperators = function(options) {
39 const flag = this._getSetting('allowExtendedOperators', options);
40 if (flag != null) return !!flag;
41 // Default to `false`
42 return false;
43};
44
45/**
46 * Get settings via hierarchical determination
47 * - method level options
48 * - model level settings
49 * - data source level settings
50 *
51 * @param {String} key The setting key
52 */
53ModelUtils._getSetting = function(key, options) {
54 // Check method level options
55 let val = options && options[key];
56 if (val !== undefined) return val;
57 // Check for settings in model
58 const m = this.definition;
59 if (m && m.settings) {
60 val = m.settings[key];
61 if (val !== undefined) {
62 return m.settings[key];
63 }
64 // Fall back to data source level
65 }
66
67 // Check for settings in connector
68 const ds = this.getDataSource();
69 if (ds && ds.settings) {
70 return ds.settings[key];
71 }
72
73 return undefined;
74};
75
76const operators = {
77 eq: '=',
78 gt: '>',
79 gte: '>=',
80 lt: '<',
81 lte: '<=',
82 between: 'BETWEEN',
83 inq: 'IN',
84 nin: 'NOT IN',
85 neq: '!=',
86 like: 'LIKE',
87 nlike: 'NOT LIKE',
88 ilike: 'ILIKE',
89 nilike: 'NOT ILIKE',
90 regexp: 'REGEXP',
91};
92
93/*
94 * Normalize the filter object and throw errors if invalid values are detected
95 * @param {Object} filter The query filter object
96 * @options {Object} [options] Optional options to use.
97 * @property {Boolean} allowExtendedOperators.
98 * @returns {Object} The normalized filter object
99 * @private
100 */
101ModelUtils._normalize = function(filter, options) {
102 if (!filter) {
103 return undefined;
104 }
105 let err = null;
106 if ((typeof filter !== 'object') || Array.isArray(filter)) {
107 err = new Error(g.f('The query filter %j is not an {{object}}', filter));
108 err.statusCode = 400;
109 throw err;
110 }
111 if (filter.limit || filter.skip || filter.offset) {
112 const limit = Number(filter.limit || 100);
113 const offset = Number(filter.skip || filter.offset || 0);
114 if (isNaN(limit) || limit <= 0 || Math.ceil(limit) !== limit) {
115 err = new Error(g.f('The {{limit}} parameter %j is not valid',
116 filter.limit));
117 err.statusCode = 400;
118 throw err;
119 }
120 if (isNaN(offset) || offset < 0 || Math.ceil(offset) !== offset) {
121 err = new Error(g.f('The {{offset/skip}} parameter %j is not valid',
122 filter.skip || filter.offset));
123 err.statusCode = 400;
124 throw err;
125 }
126 filter.limit = limit;
127 filter.offset = offset;
128 filter.skip = offset;
129 }
130
131 if (filter.order) {
132 let order = filter.order;
133 if (!Array.isArray(order)) {
134 order = [order];
135 }
136 const fields = [];
137 for (let i = 0, m = order.length; i < m; i++) {
138 if (typeof order[i] === 'string') {
139 // Normalize 'f1 ASC, f2 DESC, f3' to ['f1 ASC', 'f2 DESC', 'f3']
140 const tokens = order[i].split(/(?:\s*,\s*)+/);
141 for (let t = 0, n = tokens.length; t < n; t++) {
142 let token = tokens[t];
143 if (token.length === 0) {
144 // Skip empty token
145 continue;
146 }
147 const parts = token.split(/\s+/);
148 if (parts.length >= 2) {
149 const dir = parts[1].toUpperCase();
150 if (dir === 'ASC' || dir === 'DESC') {
151 token = parts[0] + ' ' + dir;
152 } else {
153 err = new Error(g.f('The {{order}} %j has invalid direction', token));
154 err.statusCode = 400;
155 throw err;
156 }
157 }
158 fields.push(token);
159 }
160 } else {
161 err = new Error(g.f('The order %j is not valid', order[i]));
162 err.statusCode = 400;
163 throw err;
164 }
165 }
166 if (fields.length === 1 && typeof filter.order === 'string') {
167 filter.order = fields[0];
168 } else {
169 filter.order = fields;
170 }
171 }
172
173 // normalize fields as array of included property names
174 if (filter.fields) {
175 filter.fields = fieldsToArray(filter.fields,
176 Object.keys(this.definition.properties), this.settings.strict);
177 }
178
179 filter = this._sanitizeQuery(filter, options);
180 this._coerce(filter.where, options);
181 return filter;
182};
183
184function DateType(arg) {
185 const d = new Date(arg);
186 if (isNaN(d.getTime())) {
187 throw new Error(g.f('Invalid date: %s', arg));
188 }
189 return d;
190}
191
192function BooleanType(arg) {
193 if (typeof arg === 'string') {
194 switch (arg) {
195 case 'true':
196 case '1':
197 return true;
198 case 'false':
199 case '0':
200 return false;
201 }
202 }
203 if (arg == null) {
204 return null;
205 }
206 return Boolean(arg);
207}
208
209function NumberType(val) {
210 const num = Number(val);
211 return !isNaN(num) ? num : val;
212}
213
214function coerceArray(val) {
215 if (Array.isArray(val)) {
216 return val;
217 }
218
219 if (!isPlainObject(val)) {
220 throw new Error(g.f('Value is not an {{array}} or {{object}} with sequential numeric indices'));
221 }
222
223 // It is an object, check if empty
224 const props = Object.keys(val);
225
226 if (props.length === 0) {
227 throw new Error(g.f('Value is an empty {{object}}'));
228 }
229
230 const arrayVal = new Array(props.length);
231 for (let i = 0; i < arrayVal.length; ++i) {
232 if (!val.hasOwnProperty(i)) {
233 throw new Error(g.f('Value is not an {{array}} or {{object}} with sequential numeric indices'));
234 }
235
236 arrayVal[i] = val[i];
237 }
238
239 return arrayVal;
240}
241
242function _normalizeAsArray(result) {
243 if (typeof result === 'string') {
244 result = [result];
245 }
246 if (Array.isArray(result)) {
247 return result;
248 } else {
249 // See https://github.com/strongloop/loopback-datasource-juggler/issues/1646
250 // `ModelBaseClass` normalize the properties to an object such as `{secret: true}`
251 const keys = [];
252 for (const k in result) {
253 if (result[k]) keys.push(k);
254 }
255 return keys;
256 }
257}
258
259/**
260 * Get an array of hidden property names
261 */
262ModelUtils._getHiddenProperties = function() {
263 const settings = this.definition.settings || {};
264 const result = settings.hiddenProperties || settings.hidden || [];
265 return _normalizeAsArray(result);
266};
267
268/**
269 * Get an array of protected property names
270 */
271ModelUtils._getProtectedProperties = function() {
272 const settings = this.definition.settings || {};
273 const result = settings.protectedProperties || settings.protected || [];
274 return _normalizeAsArray(result);
275};
276
277/**
278 * Get the maximum depth of a query object
279 */
280ModelUtils._getMaxDepthOfQuery = function(options, defaultValue) {
281 options = options || {};
282 // See https://github.com/strongloop/loopback-datasource-juggler/issues/1651
283 let maxDepth = this._getSetting('maxDepthOfQuery', options);
284 if (maxDepth == null) {
285 maxDepth = defaultValue || 32;
286 }
287 return +maxDepth;
288};
289
290/**
291 * Get the maximum depth of a data object
292 */
293ModelUtils._getMaxDepthOfData = function(options, defaultValue) {
294 options = options || {};
295 // See https://github.com/strongloop/loopback-datasource-juggler/issues/1651
296 let maxDepth = this._getSetting('maxDepthOfData', options);
297 if (maxDepth == null) {
298 maxDepth = defaultValue || 64;
299 }
300 return +maxDepth;
301};
302
303/**
304 * Get the prohibitHiddenPropertiesInQuery flag
305 */
306ModelUtils._getProhibitHiddenPropertiesInQuery = function(options, defaultValue) {
307 const flag = this._getSetting('prohibitHiddenPropertiesInQuery', options);
308 if (flag == null) return !!defaultValue;
309 return !!flag;
310};
311
312/**
313 * Sanitize the query object
314 */
315ModelUtils._sanitizeQuery = function(query, options) {
316 options = options || {};
317
318 // Get settings to normalize `undefined` values
319 const normalizeUndefinedInQuery = this._getSetting('normalizeUndefinedInQuery', options);
320 // Get setting to prohibit hidden/protected properties in query
321 const prohibitHiddenPropertiesInQuery = this._getProhibitHiddenPropertiesInQuery(options);
322
323 // See https://github.com/strongloop/loopback-datasource-juggler/issues/1651
324 const maxDepthOfQuery = this._getMaxDepthOfQuery(options);
325
326 let prohibitedKeys = [];
327 // Check violation of keys
328 if (prohibitHiddenPropertiesInQuery) {
329 prohibitedKeys = this._getHiddenProperties();
330 if (options.prohibitProtectedPropertiesInQuery) {
331 prohibitedKeys = prohibitedKeys.concat(this._getProtectedProperties());
332 }
333 }
334 return sanitizeQueryOrData(query,
335 Object.assign({
336 maxDepth: maxDepthOfQuery,
337 prohibitedKeys: prohibitedKeys,
338 normalizeUndefinedInQuery: normalizeUndefinedInQuery,
339 }, options));
340};
341
342/**
343 * Sanitize the data object
344 */
345ModelUtils._sanitizeData = function(data, options) {
346 options = options || {};
347 return sanitizeQueryOrData(data,
348 Object.assign({
349 maxDepth: this._getMaxDepthOfData(options),
350 }, options));
351};
352
353/*
354 * Coerce values based the property types
355 * @param {Object} where The where clause
356 * @options {Object} [options] Optional options to use.
357 * @param {Object} Optional model definition to use.
358 * @property {Boolean} allowExtendedOperators.
359 * @returns {Object} The coerced where clause
360 * @private
361 */
362ModelUtils._coerce = function(where, options, modelDef) {
363 const self = this;
364 if (where == null) {
365 return where;
366 }
367 options = options || {};
368
369 let err;
370 if (typeof where !== 'object' || Array.isArray(where)) {
371 err = new Error(g.f('The where clause %j is not an {{object}}', where));
372 err.statusCode = 400;
373 throw err;
374 }
375 let props;
376 if (modelDef && modelDef.properties) {
377 props = modelDef.properties;
378 } else {
379 props = self.definition.properties;
380 }
381
382 for (const p in where) {
383 // Handle logical operators
384 if (p === 'and' || p === 'or' || p === 'nor') {
385 let clauses = where[p];
386 try {
387 clauses = coerceArray(clauses);
388 } catch (e) {
389 err = new Error(g.f('The %s operator has invalid clauses %j: %s', p, clauses, e.message));
390 err.statusCode = 400;
391 throw err;
392 }
393
394 for (let k = 0; k < clauses.length; k++) {
395 self._coerce(clauses[k], options);
396 }
397
398 where[p] = clauses;
399
400 continue;
401 }
402 let DataType = props[p] && props[p].type;
403 if (!DataType) {
404 continue;
405 }
406
407 if ((Array.isArray(DataType) || DataType === Array) && !isNestedModel(DataType)) {
408 DataType = DataType[0];
409 }
410 if (DataType === Date) {
411 DataType = DateType;
412 } else if (DataType === Boolean) {
413 DataType = BooleanType;
414 } else if (DataType === Number) {
415 // This fixes a regression in mongodb connector
416 // For numbers, only convert it produces a valid number
417 // LoopBack by default injects a number id. We should fix it based
418 // on the connector's input, for example, MongoDB should use string
419 // while RDBs typically use number
420 DataType = NumberType;
421 }
422
423 if (!DataType) {
424 continue;
425 }
426
427 if (DataType === geo.GeoPoint) {
428 // Skip the GeoPoint as the near operator breaks the assumption that
429 // an operation has only one property
430 // We should probably fix it based on
431 // http://docs.mongodb.org/manual/reference/operator/query/near/
432 // The other option is to make operators start with $
433 continue;
434 }
435
436 let val = where[p];
437 if (val === null || val === undefined) {
438 continue;
439 }
440 // Check there is an operator
441 let operator = null;
442 const exp = val;
443 if (val.constructor === Object) {
444 for (const op in operators) {
445 if (op in val) {
446 val = val[op];
447 operator = op;
448 switch (operator) {
449 case 'inq':
450 case 'nin':
451 case 'between':
452 try {
453 val = coerceArray(val);
454 } catch (e) {
455 err = new Error(g.f('The %s property has invalid clause %j: %s', p, where[p], e));
456 err.statusCode = 400;
457 throw err;
458 }
459
460 if (operator === 'between' && val.length !== 2) {
461 err = new Error(g.f(
462 'The %s property has invalid clause %j: Expected precisely 2 values, received %d',
463 p,
464 where[p],
465 val.length,
466 ));
467 err.statusCode = 400;
468 throw err;
469 }
470 break;
471 case 'like':
472 case 'nlike':
473 case 'ilike':
474 case 'nilike':
475 if (!(typeof val === 'string' || val instanceof RegExp)) {
476 err = new Error(g.f(
477 'The %s property has invalid clause %j: Expected a string or RegExp',
478 p,
479 where[p],
480 ));
481 err.statusCode = 400;
482 throw err;
483 }
484 break;
485 case 'regexp':
486 val = toRegExp(val);
487 if (val instanceof Error) {
488 val.statusCode = 400;
489 throw val;
490 }
491 break;
492 }
493 break;
494 }
495 }
496 }
497
498 try {
499 // Coerce val into an array if it resembles an array-like object
500 val = coerceArray(val);
501 } catch (e) {
502 // NOOP when not coercable into an array.
503 }
504
505 const allowExtendedOperators = self._allowExtendedOperators(options);
506 // Coerce the array items
507 if (Array.isArray(val) && !isNestedModel(DataType)) {
508 for (let i = 0; i < val.length; i++) {
509 if (val[i] !== null && val[i] !== undefined) {
510 if (!(val[i] instanceof RegExp)) {
511 val[i] = isClass(DataType) ? new DataType(val[i]) : DataType(val[i]);
512 }
513 }
514 }
515 } else {
516 if (val != null) {
517 if (operator === null && val instanceof RegExp) {
518 // Normalize {name: /A/} to {name: {regexp: /A/}}
519 operator = 'regexp';
520 } else if (operator === 'regexp' && val instanceof RegExp) {
521 // Do not coerce regex literals/objects
522 } else if ((operator === 'like' || operator === 'nlike' ||
523 operator === 'ilike' || operator === 'nilike') && val instanceof RegExp) {
524 // Do not coerce RegExp operator value
525 } else if (allowExtendedOperators && typeof val === 'object') {
526 // Do not coerce object values when extended operators are allowed
527 } else {
528 if (!allowExtendedOperators) {
529 const extendedOperators = Object.keys(val).filter(function(k) {
530 return k[0] === '$';
531 });
532 if (extendedOperators.length) {
533 const msg = g.f('Operators "' + extendedOperators.join(', ') + '" are not allowed in query');
534 const err = new Error(msg);
535 err.code = 'OPERATOR_NOT_ALLOWED_IN_QUERY';
536 err.statusCode = 400;
537 err.details = {
538 operators: extendedOperators,
539 where: where,
540 };
541 throw err;
542 }
543 }
544 if (isNestedModel(DataType)) {
545 if (Array.isArray(DataType) && Array.isArray(val)) {
546 if (val === null || val === undefined) continue;
547 for (const it of val) {
548 self._coerce(it, options, DataType[0].definition);
549 }
550 } else {
551 self._coerce(val, options, DataType.definition);
552 }
553 continue;
554 } else {
555 val = isClass(DataType) ? new DataType(val) : DataType(val);
556 }
557 }
558 }
559 }
560 // Rebuild {property: {operator: value}}
561 if (operator && operator !== 'eq') {
562 const value = {};
563 value[operator] = val;
564 if (exp.options) {
565 // Keep options for operators
566 value.options = exp.options;
567 }
568 val = value;
569 }
570 where[p] = val;
571 }
572 return where;
573};
574
575/**
576* A utility function which checks for nested property definitions
577*
578* @param {*} propType Property type metadata
579*
580*/
581function isNestedModel(propType) {
582 if (!propType) return false;
583 if (Array.isArray(propType)) return isNestedModel(propType[0]);
584 return propType.hasOwnProperty('definition') && propType.definition.hasOwnProperty('properties');
585}
586