• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mia-platform / crud-service / 5288232769

pending completion
5288232769

Pull #92

github

web-flow
Merge a47d150c2 into 8334e4887
Pull Request #92: Feat/writable views

962 of 1075 branches covered (89.49%)

Branch coverage included in aggregate %.

168 of 168 new or added lines in 5 files covered. (100.0%)

1947 of 2021 relevant lines covered (96.34%)

21691.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.23
/lib/QueryParser.js
1
/* eslint-disable no-underscore-dangle */
2
/*
3
 * Copyright 2023 Mia s.r.l.
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 *     http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 */
17

18
'use strict'
19

20
const { ObjectId } = require('mongodb')
104✔
21
const {
22
  ARRAY_MERGE_ELEMENT_OPERATOR,
23
  ARRAY_REPLACE_ELEMENT_OPERATOR,
24
  RAWOBJECTTYPE,
25
  ARRAY,
26
  JSON_SCHEMA_ARRAY_TYPE,
27
  TEXT_INDEX,
28
  NORMAL_INDEX,
29
  JSON_SCHEMA_OBJECT_TYPE,
30
  DATE,
31
  DATE_FORMATS,
32
  OBJECTID,
33
  GEOPOINT,
34
} = require('../lib/consts')
104✔
35

36
class QueryParser {
37
  constructor(collectionDefinition, pathsForRawSchema) {
38
    this._fieldDefinition = getFieldDefinition(collectionDefinition)
4,070✔
39
    this._nullableFields = getNullableFields(collectionDefinition)
4,070✔
40
    this._rawObjectAccesses = getRawObjectFields(collectionDefinition)
4,070✔
41
    this._pathsForRawSchema = pathsForRawSchema
4,070✔
42
    this._textIndexFields = getTextIndexes(collectionDefinition, TEXT_INDEX)
4,070✔
43
    this._normalIndexes = getTextIndexes(collectionDefinition, NORMAL_INDEX)
4,070✔
44

45
    this.traverseBinded = value => {
4,070✔
46
      traverse(this._fieldDefinition, this.traverseBinded, value)
84,396✔
47
    }
48

49
    this.traverseBody = value => {
4,070✔
50
      traverseBody(this._fieldDefinition, this._nullableFields, value)
200,290✔
51
    }
52

53
    this.traverseCommands = (commands, editableFields) => {
4,070✔
54
      traverseCommands(
80,810✔
55
        this._fieldDefinition,
56
        this._nullableFields,
57
        this._rawObjectAccesses,
58
        commands,
59
        editableFields,
60
        this._pathsForRawSchema
61
      )
62
    }
63

64
    this.traverseTextSearchQuery = (query) => {
4,070✔
65
      startTraverseTextSearchQuery(query, this._normalIndexes, this._fieldDefinition, this.traverseBinded)
56✔
66
    }
67

68
    this.isTextSearchQuery = (query) => checkIfTextSearchQuery(query)
4,070✔
69

70
    this.parseAndCastBody = this.parseAndCastBody.bind(this)
4,070✔
71
  }
72

73
  parseAndCast(query) {
74
    this.traverseBinded(query)
82,520✔
75
  }
76

77
  parseAndCastBody(doc) {
78
    this.traverseBody(doc)
200,290✔
79
  }
80

81
  parseAndCastCommands(commands, editableFields) {
82
    this.traverseCommands(commands, editableFields)
80,810✔
83
  }
84

85
  parseAndCastTextSearchQuery(query) {
86
    this.traverseTextSearchQuery(query)
56✔
87
  }
88

89
  // TODO: understand why this!
90
  isTextSearchQuery(query) {
91
    return this.isTextSearchQuery(query)
×
92
  }
93
}
94

95
function castDate(value) {
96
  if (value === null) {
152✔
97
    return null
8✔
98
  }
99

100
  const type = typeof value
144✔
101

102
  if (type === 'number' || type === 'string') {
144!
103
    const date = new Date(value)
144✔
104
    if (!isNaN(date.getTime())) { return date }
144✔
105
  }
106
  throw new Error('Invalid Date')
4✔
107
}
108

109
function castToGeoPoint(value) {
110
  return { type: 'Point', coordinates: value }
140✔
111
}
112

113
function castArray(value) {
114
  return value
66✔
115
}
116

117
function castToObjectId(value) {
118
  if (value === null) {
182✔
119
    return value
8✔
120
  }
121

122
  // Number are accepted by mongodb as timestamp
123
  // for generating the ObjectId in that time
124
  if (ObjectId.isValid(value) && typeof value !== 'number') {
174✔
125
    return new ObjectId(value)
166✔
126
  }
127
  throw new Error('Invalid objectId')
8✔
128
}
129

130
function callCastFunctionOnValue(value, castFunction) {
131
  return castFunction(value)
680✔
132
}
133
callCastFunctionOnValue.supportedTypes = {
104✔
134
  string: true,
135
  boolean: true,
136
  number: true,
137
  Date: true,
138
  ObjectId: true,
139
  Array: false,
140
  GeoPoint: true,
141
}
142

143
function callCastFunctionOnArray(array, castFunction) {
144
  return array.map(castFunction)
88✔
145
}
146
callCastFunctionOnArray.supportedTypes = {
104✔
147
  string: true,
148
  boolean: true,
149
  number: true,
150
  Date: true,
151
  ObjectId: true,
152
  Array: true,
153
  GeoPoint: true,
154
}
155

156
function castNearSphere(value, castFunction) {
157
  const nearShpere = { $geometry: castFunction(value.from) }
56✔
158

159
  if (value.minDistance) {
56✔
160
    nearShpere.$minDistance = value.minDistance
16✔
161
  }
162
  if (value.maxDistance) {
56✔
163
    nearShpere.$maxDistance = value.maxDistance
24✔
164
  }
165

166
  return nearShpere
56✔
167
}
168
castNearSphere.supportedTypes = {
104✔
169
  string: false,
170
  boolean: false,
171
  number: false,
172
  Date: false,
173
  ObjectId: false,
174
  Array: false,
175
  GeoPoint: true,
176
}
177

178
function callExists(value) {
179
  return value
20✔
180
}
181
callExists.supportedTypes = {
104✔
182
  string: true,
183
  boolean: true,
184
  number: true,
185
  Date: true,
186
  ObjectId: true,
187
  Array: true,
188
  GeoPoint: true,
189
}
190

191
function callElemMatch(value) {
192
  return value
×
193
}
194
callElemMatch.supportedTypes = {
104✔
195
  string: false,
196
  boolean: false,
197
  number: false,
198
  Date: false,
199
  ObjectId: false,
200
  Array: true,
201
  GeoPoint: false,
202
}
203

204
const castValueFunctions = {
104✔
205
  [DATE]: castDate,
206
  [OBJECTID]: castToObjectId,
207
  [GEOPOINT]: castToGeoPoint,
208
  [ARRAY]: castArray,
209
}
210

211
const traverseOperatorFunctions = {
104✔
212
  $gt: callCastFunctionOnValue,
213
  $lt: callCastFunctionOnValue,
214
  $gte: callCastFunctionOnValue,
215
  $lte: callCastFunctionOnValue,
216
  $eq: callCastFunctionOnValue,
217
  $ne: callCastFunctionOnValue,
218
  $in: callCastFunctionOnArray,
219
  $nin: callCastFunctionOnArray,
220
  $all: callCastFunctionOnArray,
221
  $exists: callExists,
222
  $nearSphere: castNearSphere,
223
  $regex: callCastFunctionOnValue,
224
  $elemMatch: callElemMatch,
225
  // for $regex
226
  $options: callCastFunctionOnValue,
227
}
228

229
function identity(value) { return value }
1,154✔
230

231
// eslint-disable-next-line max-statements
232
function traverse(fieldDefinition, traverseBinded, query) {
233
  for (const key of Object.keys(query)) {
84,452✔
234
    const fieldType = fieldDefinition[key.split('.')[0]]
84,416✔
235
    if (fieldType === RAWOBJECTTYPE || fieldType === ARRAY) { continue }
84,416✔
236

237
    if (key === '$and' || key === '$or') {
83,950✔
238
      query[key].forEach(traverseBinded)
82,422✔
239
      continue
82,422✔
240
    }
241
    if (key === '$text') {
1,528✔
242
      continue
56✔
243
    }
244

245
    if (key[0] === '$') {
1,472✔
246
      throw new Error(`Unknown operator: ${key}`)
4✔
247
    }
248
    if (!fieldDefinition[key]) {
1,468✔
249
      throw new Error(`Unknown field: ${key}`)
8✔
250
    }
251

252
    const type = fieldDefinition[key]
1,460✔
253
    const castValueFunction = castValueFunctions[type] || identity
1,460✔
254

255
    const value = query[key]
1,460✔
256
    if (value === undefined) { continue }
1,460!
257
    if (value === null || (value && value.constructor !== Object)) {
1,460✔
258
      query[key] = castValueFunction(value, fieldDefinition[key])
600✔
259
      continue
588✔
260
    }
261

262
    const queryKey = Object.keys(value)
860✔
263
    for (const operator of queryKey) {
860✔
264
      const traverseOperatorFunction = traverseOperatorFunctions[operator]
860✔
265
      if (!traverseOperatorFunction) {
860✔
266
        throw new Error(`Unsupported operator: ${operator}`)
16✔
267
      }
268
      if (!traverseOperatorFunction.supportedTypes[type]) {
844!
269
        throw new Error(`Unsupported operator: ${operator} for ${type} field`)
×
270
      }
271

272
      const operatorValue = value[operator]
844✔
273
      query[key][operator] = traverseOperatorFunction(operatorValue, castValueFunction)
844✔
274
    }
275
  }
276
}
277

278
function traverseBody(fieldDefinition, nullableFields, doc) {
279
  const keys = Object.keys(doc)
200,290✔
280
  for (const key of keys) {
200,290✔
281
    if (!fieldDefinition[key]) {
601,322✔
282
      throw new Error(`Unknown field: ${key}`)
4✔
283
    }
284

285
    const type = fieldDefinition[key]
601,318✔
286
    const castValueFunction = castValueFunctions[type]
601,318✔
287
    if (!castValueFunction) {
601,318✔
288
      continue
601,042✔
289
    }
290

291
    const value = doc[key]
276✔
292
    if (value === null && nullableFields[key]) {
276✔
293
      continue
20✔
294
    }
295

296
    doc[key] = castValueFunction(value)
256✔
297
  }
298
}
299

300
function getFieldDefinitionCompatibility(collectionDefinition) {
301
  return collectionDefinition
4,030✔
302
    .fields
303
    .reduce((acc, field) => {
304
      acc[field.name] = field.type
43,366✔
305
      return acc
43,366✔
306
    }, {})
307
}
308

309
function getFieldDefinition(collectionDefinition) {
310
  if (!collectionDefinition.schema) {
4,070✔
311
    return getFieldDefinitionCompatibility(collectionDefinition)
4,030✔
312
  }
313

314
  return Object
40✔
315
    .entries(collectionDefinition.schema.properties)
316
    .reduce((acc, [propertyName, jsonSchema]) => {
317
      if (jsonSchema.__mia_configuration?.type) {
596✔
318
        acc[propertyName] = jsonSchema.__mia_configuration.type
76✔
319
        return acc
76✔
320
      }
321

322
      if (jsonSchema.type === 'string' && DATE_FORMATS.includes(jsonSchema.format ?? '')) {
520✔
323
        acc[propertyName] = DATE
92✔
324
        return acc
92✔
325
      }
326

327
      if (jsonSchema.type === JSON_SCHEMA_OBJECT_TYPE) {
428✔
328
        acc[propertyName] = RAWOBJECTTYPE
60✔
329
        return acc
60✔
330
      }
331

332
      if (jsonSchema.type === JSON_SCHEMA_ARRAY_TYPE) {
368✔
333
        acc[propertyName] = ARRAY
88✔
334
        return acc
88✔
335
      }
336

337
      acc[propertyName] = jsonSchema.type
280✔
338
      return acc
280✔
339
    }, {})
340
}
341

342
function getNullableFieldsCompatibility(collectionDefinition) {
343
  return collectionDefinition
4,030✔
344
    .fields
345
    .reduce((acc, field) => {
346
      if (field.nullable === true) {
43,366✔
347
        acc[field.name] = true
3,345✔
348
      }
349
      return acc
43,366✔
350
    }, {})
351
}
352

353
function getNullableFields(collectionDefinition) {
354
  if (!collectionDefinition.schema) {
4,070✔
355
    return getNullableFieldsCompatibility(collectionDefinition)
4,030✔
356
  }
357

358
  return Object
40✔
359
    .entries(collectionDefinition.schema.properties)
360
    .reduce((acc, [propertyName, jsonSchema]) => {
361
      acc[propertyName] = jsonSchema.nullable
596✔
362
      return acc
596✔
363
    }, {})
364
}
365

366
function getRawObjectFieldsCompatibility(collectionDefinition) {
367
  return collectionDefinition
4,030✔
368
    .fields
369
    .reduce((acc, field) => {
370
      if (field.type !== RAWOBJECTTYPE) {
43,366✔
371
        return acc
41,355✔
372
      }
373
      acc.push(new RegExp(`^${field.name}\\.`))
2,011✔
374
      return acc
2,011✔
375
    }, [])
376
}
377

378
function getRawObjectFields(collectionDefinition) {
379
  if (!collectionDefinition.schema) {
4,070✔
380
    return getRawObjectFieldsCompatibility(collectionDefinition)
4,030✔
381
  }
382

383
  return Object
40✔
384
    .entries(collectionDefinition.schema.properties)
385
    .reduce((acc, [propertyName, jsonSchema]) => {
386
      if (jsonSchema.type !== JSON_SCHEMA_OBJECT_TYPE) {
596✔
387
        return acc
508✔
388
      }
389
      acc.push(new RegExp(`^${propertyName}\\.`))
88✔
390
      return acc
88✔
391
    }, [])
392
}
393

394
function getTextIndexes(collectionDefinition, type) {
395
  if (!collectionDefinition.indexes) {
8,140✔
396
    return []
446✔
397
  }
398
  return collectionDefinition
7,694✔
399
    .indexes
400
    .filter(index => index.type === type)
12,346✔
401
    .reduce((acc, index) => [...acc, ...index.fields.map(el => el.name)], [])
5,942✔
402
}
403

404
function transformArrayMergeCommands(arrayName, arrayElementFields, fieldName, changesForCommand) {
405
  Object.keys(arrayElementFields).forEach((key) => {
48✔
406
    changesForCommand[`${arrayName}.$.${key}`] = arrayElementFields[key]
68✔
407
  })
408
  delete changesForCommand[fieldName]
48✔
409
}
410

411
function transformReplaceCommands(arrayName, fieldName, changesForCommand) {
412
  changesForCommand[`${arrayName}.$`] = changesForCommand[fieldName]
52✔
413
  delete changesForCommand[fieldName]
52✔
414
}
415

416
function transformArrayCommands(arrayData, changesForCommand) {
417
  const { arrayName, arrayElementFields, fieldName, arrayOperation } = arrayData
604✔
418
  if (!arrayElementFields) {
604✔
419
    throw new Error('Invalid value for array operation')
8✔
420
  }
421
  const isAMerge = (arrayOperation === `.${ARRAY_MERGE_ELEMENT_OPERATOR}`)
596✔
422
  const isAReplace = (arrayOperation === `.${ARRAY_REPLACE_ELEMENT_OPERATOR}`)
596✔
423
  if (isAMerge) {
596✔
424
    transformArrayMergeCommands(arrayName, arrayElementFields, fieldName, changesForCommand)
48✔
425
  }
426
  if (isAReplace) {
596✔
427
    transformReplaceCommands(arrayName, fieldName, changesForCommand)
52✔
428
  }
429
}
430

431
// eslint-disable-next-line max-statements
432
function traverseCommands(
433
  fieldDefinition,
434
  nullableFields,
435
  rawObjectAccesses,
436
  commands,
437
  editableFields,
438
  pathsForRawSchema
439
) {
440
  for (const key of Object.keys(commands)) {
80,810✔
441
    if (key === '$unset' || key === '$currentDate') {
80,826✔
442
      continue
56✔
443
    }
444

445
    const changesForCommand = commands[key]
80,770✔
446
    const fieldKeys = Object.keys(changesForCommand)
80,770✔
447

448
    for (const fieldName of fieldKeys) {
80,770✔
449
      const splitFieldName = fieldName.split('.$')
81,226✔
450
      let isFieldAnArray = (fieldDefinition[splitFieldName[0]] === ARRAY)
81,226✔
451
      if (!isFieldAnArray) {
81,226✔
452
        const rawSchemaForField = getRawSchemaFieldPath(splitFieldName[0], pathsForRawSchema)
80,822✔
453
        isFieldAnArray = rawSchemaForField && rawSchemaForField.type === JSON_SCHEMA_ARRAY_TYPE
80,822✔
454
      }
455
      if (isFieldAnArray) {
81,226✔
456
        const arrayData = {
604✔
457
          arrayName: splitFieldName[0],
458
          arrayElementFields: commands[key][fieldName],
459
          fieldName,
460
          arrayOperation: splitFieldName[1],
461
        }
462
        transformArrayCommands(arrayData, changesForCommand)
604✔
463

464
        continue
596✔
465
      }
466

467
      if (Boolean(
80,622✔
468
        getRawSchemaFieldPath(fieldName, pathsForRawSchema))
469
        || rawObjectAccesses.some(objKey => objKey.test(fieldName))
241,508✔
470
      ) {
471
        // Allow anything
472
        continue
124✔
473
      }
474
      if (!fieldDefinition[fieldName]) {
80,498✔
475
        throw new Error('Unknown fields')
4✔
476
      }
477
      const type = fieldDefinition[fieldName]
80,494✔
478

479
      if (!editableFields.includes(fieldName)) {
80,494✔
480
        throw new Error(`You cannot edit "${fieldName}" field`)
4✔
481
      }
482

483
      const castValueFunction = castValueFunctions[type]
80,490✔
484
      if (!castValueFunction) {
80,490✔
485
        continue
80,472✔
486
      }
487
      const value = changesForCommand[fieldName]
18✔
488
      if (value === null && nullableFields[fieldName]) {
18✔
489
        continue
4✔
490
      }
491
      changesForCommand[fieldName] = castValueFunction(value)
14✔
492
    }
493
  }
494
}
495

496
function getRawSchemaFieldPath(fieldPath, rawSchemas = {}) {
×
497
  const { paths } = rawSchemas
161,444✔
498
  if (paths[fieldPath]) {
161,444✔
499
    return paths[fieldPath]
196✔
500
  }
501

502
  // we do this sort to match the strictest regex
503
  // in some cases we could have a shorter regex matching the pattern
504
  // and returning the element.
505
  const patternProperties = Object
161,248✔
506
    .keys(rawSchemas.patternProperties ?? {})
161,248!
507
    .sort((firstRegex, secondRegex) => secondRegex.length - firstRegex.length)
11,285,064✔
508

509
  const fromPatternProperties = patternProperties.find(pattern => new RegExp(pattern).test(fieldPath))
3,543,776✔
510
  if (fromPatternProperties) {
161,248✔
511
    return rawSchemas.patternProperties[fromPatternProperties]
220✔
512
  }
513

514
  return null
161,028✔
515
}
516

517

518
// eslint-disable-next-line max-statements
519
function traverseTextSearchQuery(query, normalIndexes) {
520
  const keys = Object.keys(query)
164✔
521
  for (const key of keys) {
164✔
522
    if (key === '$text') {
164✔
523
      if (countTextOccurrences(query[key]) > 1) {
52!
524
        throw new Error(`Query ${query} has more than one $text expression`)
×
525
      }
526

527
      const textQueryKeys = Object.keys(query[key])
52✔
528
      if (!textQueryKeys.includes('$search')) {
52!
529
        throw new Error(`$text search query ${query[key]} must include $search field`)
×
530
      }
531

532
      for (const textOptionKey of textQueryKeys) {
52✔
533
        if (!['$search', '$language', '$caseSensitive', '$diacriticSensitive'].includes(textOptionKey)) {
92!
534
          throw new Error(`Unknown option for $text search query: ${textOptionKey}`)
×
535
        }
536
      }
537

538
      continue
52✔
539
    }
540
    if (key === '$nor') {
112!
541
      query.$nor.forEach(clause => {
×
542
        if (checkIfTextSearchQuery(clause)) {
×
543
          throw new Error('$text can not appear in a $nor expression')
×
544
        }
545
      })
546

547
      continue
×
548
    }
549
    if (key === '$elemMatch') {
112!
550
      query.$elemMatch.split(',').forEach(clause => {
×
551
        if (checkIfTextSearchQuery(clause)) {
×
552
          throw new Error('$text query can not appear in a $elemMatch query expression')
×
553
        }
554
      })
555

556
      continue
×
557
    }
558
    if (key === '$or') {
112✔
559
      query[key].forEach(clause => {
16✔
560
        Object.keys(clause).forEach(orKey => {
28✔
561
          if (orKey[0] !== '$' && !normalIndexes.includes(orKey)) {
28✔
562
            throw new Error('To use a $text query in an $or expression, all clauses in the $or array must be indexed')
4✔
563
          }
564
          traverseTextSearchQuery(clause, normalIndexes)
24✔
565
        })
566
      })
567

568
      continue
12✔
569
    }
570
    if (key[0] === '$') {
96✔
571
      const clauses = query[key]
68✔
572
      if (Array.isArray(clauses)) {
68!
573
        clauses.forEach(clause => traverseTextSearchQuery(clause, normalIndexes))
84✔
574
        continue
68✔
575
      }
576

577
      if ((typeof clauses) === 'object') {
×
578
        // eslint-disable-next-line id-length
579
        Object.keys(clauses).forEach(k => traverseTextSearchQuery(clauses[k], normalIndexes))
×
580
        continue
×
581
      }
582
    }
583
  }
584
}
585

586
function startTraverseTextSearchQuery(query, normalIndexes, fieldDefinition, traverseBinded) {
587
  // $text query should conform both to standard query rules and to $text query rules
588
  traverse(fieldDefinition, traverseBinded, query)
56✔
589
  traverseTextSearchQuery(query, normalIndexes)
56✔
590
}
591

592
const recursiveSearch = (query, searchKey, results = []) => {
104!
593
  Object.keys(query).forEach(key => {
514✔
594
    const value = query[key]
628✔
595
    if (key === searchKey) {
628✔
596
      results.push(value)
56✔
597
    } else if (value && typeof value === 'object') {
572✔
598
      recursiveSearch(value, searchKey, results)
232✔
599
    }
600
  })
601
  return results
506✔
602
}
603

604
function checkIfTextSearchQuery(query) {
605
  const occurrences = recursiveSearch(query, '$text', [])
230✔
606
  return Array.isArray(occurrences) && occurrences.length > 0
222✔
607
}
608

609
function countTextOccurrences(query) {
610
  const occurrences = recursiveSearch(query, '$text', [])
52✔
611
  return occurrences.length
52✔
612
}
613

614
// eslint-disable-next-line max-lines
615
module.exports = QueryParser
104✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc