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

shiftcode / dynamo-easy / 15000319502

13 May 2025 03:14PM UTC coverage: 95.414% (-0.1%) from 95.54%
15000319502

push

github

web-flow
feat(expression-builder): allow for empty arrays and objects

* docs(todo): update to post-v3 todos

* feat(expression-builder): allow for empty arrays, Sets and objects

* feat(expression-builder): revert for sets

1010 of 1089 branches covered (92.75%)

Branch coverage included in aggregate %.

2 of 2 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

2215 of 2291 relevant lines covered (96.68%)

230.73 hits per line

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

94.17
/src/dynamo/expression/condition-expression-builder.ts
1
/**
2
 * @module expression
3
 */
4
import { Metadata } from '../../decorator/metadata/metadata'
5
import {
84✔
6
  alterCollectionPropertyMetadataForSingleItem,
7
  PropertyMetadata,
8
} from '../../decorator/metadata/property-metadata.model'
9
import { curry } from '../../helper/curry.function'
84✔
10
import { isPlainObject } from '../../helper/is-plain-object.function'
84✔
11
import { toDbOne } from '../../mapper/mapper'
84✔
12
import { Attribute, Attributes } from '../../mapper/type/attribute.type'
13
import { typeOf } from '../../mapper/util'
84✔
14
import { resolveAttributeNames } from './functions/attribute-names.function'
84✔
15
import { isFunctionOperator } from './functions/is-function-operator.function'
84✔
16
import { isNoParamFunctionOperator } from './functions/is-no-param-function-operator.function'
84✔
17
import { operatorParameterArity } from './functions/operator-parameter-arity.function'
84✔
18
import { uniqueAttributeValueName } from './functions/unique-attribute-value-name.function'
84✔
19
import { ConditionOperator } from './type/condition-operator.type'
20
import { Expression } from './type/expression.type'
21
import { validateAttributeType } from './update-expression-builder'
84✔
22
import { dynamicTemplate } from './util'
84✔
23

24
/**
25
 * @hidden
26
 */
27
type BuildFilterFn = (
28
  attributePath: string,
29
  namePlaceholder: string,
30
  valuePlaceholder: string,
31
  attributeNames: Record<string, string>,
32
  values: any[],
33
  existingValueNames: string[] | undefined,
34
  propertyMetadata: PropertyMetadata<any> | undefined,
35
) => Expression
36

37
/**
38
 * see http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html
39
 * https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Condition.html
40
 */
41

42
/**
43
 * Will walk the object tree recursively and removes all items that do not satisfy the filterFn
44
 * @param obj
45
 * @param {(value: any) => boolean} filterFn
46
 * @returns {any}
47
 * @hidden
48
 */
49
export function deepFilter(obj: any, filterFn: (value: any) => boolean): any {
84✔
50
  if (Array.isArray(obj)) {
872✔
51
    const returnArr: any[] = []
414✔
52
    obj.forEach((i) => {
414✔
53
      const item = deepFilter(i, filterFn)
424✔
54
      if (item !== null) {
424✔
55
        returnArr.push(item)
416✔
56
      }
57
    })
58

59
    return returnArr
414✔
60
  } else if (obj instanceof Set) {
458✔
61
    const returnArr: any[] = []
8✔
62
    Array.from(obj).forEach((i) => {
8✔
63
      const item = deepFilter(i, filterFn)
18✔
64
      if (item !== null) {
18✔
65
        returnArr.push(item)
16✔
66
      }
67
    })
68

69
    return returnArr.length ? new Set(returnArr) : null
8!
70
  } else if (isPlainObject(obj)) {
450✔
71
    const returnObj: Record<string, any> = {}
32✔
72

73
    for (const key in obj) {
32✔
74
      if (obj.hasOwnProperty(key)) {
60✔
75
        const value = obj[key]
60✔
76
        const item = deepFilter(value, filterFn)
60✔
77
        if (item !== null) {
60✔
78
          returnObj[key] = item
54✔
79
        }
80
      }
81
    }
82

83
    return returnObj
32✔
84
  } else {
85
    if (filterFn(obj)) {
418✔
86
      return obj
402✔
87
    } else {
88
      return null
16✔
89
    }
90
  }
91
}
92

93
/**
94
 * Will create a condition which can be added to a request using the param object.
95
 * It will create the expression statement and the attribute names and values.
96
 *
97
 * @param {string} attributePath
98
 * @param {ConditionOperator} operator
99
 * @param {any[]} values Depending on the operator the amount of values differs
100
 * @param {string[]} existingValueNames If provided the existing names are used to make sure we have a unique name for the current attributePath
101
 * @param {Metadata<any>} metadata If provided we use the metadata to define the attribute name and use it to map the given value(s) to attributeValue(s)
102
 * @returns {Expression}
103
 * @hidden
104
 */
105
export function buildFilterExpression(
84✔
106
  attributePath: string,
107
  operator: ConditionOperator,
108
  values: any[],
109
  existingValueNames: string[] | undefined,
110
  metadata: Metadata<any> | undefined,
111
): Expression {
112
  // metadata get rid of undefined values
113
  values = deepFilter(values, (value) => value !== undefined)
210✔
114

115
  // check if provided values are valid for given operator
116
  validateForOperator(operator, values)
210✔
117

118
  // load property metadata if model metadata was provided
119
  let propertyMetadata: PropertyMetadata<any> | undefined
120
  if (metadata) {
202✔
121
    propertyMetadata = metadata.forProperty(attributePath)
94✔
122
  }
123

124
  /*
125
   * resolve placeholder and valuePlaceholder names (same as attributePath if it not already exists)
126
   * myProp -> #myProp for name placeholder and :myProp for value placeholder
127
   *
128
   * person[0] -> #person: person
129
   * person.list[0].age -> #person: person, #attr: attr, #age: age
130
   * person.age
131
   */
132
  const resolvedAttributeNames = resolveAttributeNames(attributePath, metadata)
202✔
133
  const valuePlaceholder = uniqueAttributeValueName(attributePath, existingValueNames)
202✔
134

135
  /*
136
   * build the statement
137
   */
138
  let buildFilterFn: BuildFilterFn
139

140
  switch (operator) {
202✔
141
    case 'IN':
142
      buildFilterFn = buildInConditionExpression
4✔
143
      break
4✔
144
    case 'BETWEEN':
145
      buildFilterFn = buildBetweenConditionExpression
10✔
146
      break
10✔
147
    default:
148
      buildFilterFn = curry(buildDefaultConditionExpression)(operator)
188✔
149
  }
150

151
  return buildFilterFn(
202✔
152
    attributePath,
153
    resolvedAttributeNames.placeholder,
154
    valuePlaceholder,
155
    resolvedAttributeNames.attributeNames,
156
    values,
157
    existingValueNames,
158
    propertyMetadata,
159
  )
160
}
161

162
/**
163
 * IN expression is unlike all the others property the operand is an array of unwrapped values (not attribute values)
164
 *
165
 * @param {string} attributePath
166
 * @param {string[]} values
167
 * @param {string[]} existingValueNames
168
 * @param {PropertyMetadata<any>} propertyMetadata
169
 * @returns {Expression}
170
 * @hidden
171
 */
172
function buildInConditionExpression(
173
  _attributePath: string,
174
  namePlaceholder: string,
175
  valuePlaceholder: string,
176
  attributeNames: Record<string, string>,
177
  values: any[],
178
  _existingValueNames: string[] | undefined,
179
  propertyMetadata: PropertyMetadata<any> | undefined,
180
): Expression {
181
  const attributeValues: Attributes<any> = (<any[]>values[0])
4✔
182
    .map((value) => toDbOne(value, propertyMetadata))
6✔
183
    .reduce((result, mappedValue: Attribute | null, index: number) => {
184
      if (mappedValue !== null) {
6✔
185
        validateAttributeType('IN condition', mappedValue, 'S', 'N', 'B')
6✔
186
        result[`${valuePlaceholder}_${index}`] = mappedValue
6✔
187
      }
188
      return result
6✔
189
    }, <Attributes<any>>{})
190

191
  const inStatement = (<any[]>values[0]).map((_value: any, index: number) => `${valuePlaceholder}_${index}`).join(', ')
6✔
192

193
  return {
4✔
194
    statement: `${namePlaceholder} IN (${inStatement})`,
195
    attributeNames,
196
    attributeValues,
197
  }
198
}
199

200
/**
201
 * @hidden
202
 */
203
function buildBetweenConditionExpression(
204
  attributePath: string,
205
  namePlaceholder: string,
206
  valuePlaceholder: string,
207
  attributeNames: Record<string, string>,
208
  values: string[],
209
  existingValueNames: string[] | undefined,
210
  propertyMetadata: PropertyMetadata<any> | undefined,
211
): Expression {
212
  const attributeValues: Attributes<any> = {}
10✔
213
  const mappedValue1 = toDbOne(values[0], propertyMetadata)
10✔
214
  const mappedValue2 = toDbOne(values[1], propertyMetadata)
10✔
215

216
  if (mappedValue1 === null || mappedValue2 === null) {
10!
217
    throw new Error('make sure to provide an actual value for te BETWEEN operator')
×
218
  }
219
  ;[mappedValue1, mappedValue2].forEach((mv) => validateAttributeType('between', mv, 'S', 'N', 'B'))
20✔
220

221
  const value2Placeholder = uniqueAttributeValueName(attributePath, [valuePlaceholder].concat(existingValueNames || []))
10✔
222

223
  const statement = `${namePlaceholder} BETWEEN ${valuePlaceholder} AND ${value2Placeholder}`
10✔
224
  attributeValues[valuePlaceholder] = mappedValue1
10✔
225
  attributeValues[value2Placeholder] = mappedValue2
10✔
226

227
  return {
10✔
228
    statement,
229
    attributeNames,
230
    attributeValues,
231
  }
232
}
233

234
/**
235
 * @hidden
236
 */
237
function buildDefaultConditionExpression(
238
  operator: ConditionOperator,
239
  _attributePath: string,
240
  namePlaceholder: string,
241
  valuePlaceholder: string,
242
  attributeNames: Record<string, string>,
243
  values: any[],
244
  _exisingValueNames: string[] | undefined,
245
  propertyMetadata: PropertyMetadata<any> | undefined,
246
): Expression {
247
  let statement: string
248
  let hasValue = true
188✔
249
  if (isFunctionOperator(operator)) {
188✔
250
    if (isNoParamFunctionOperator(operator)) {
82✔
251
      statement = `${operator} (${namePlaceholder})`
28✔
252
      hasValue = false
28✔
253
    } else {
254
      statement = `${operator} (${namePlaceholder}, ${valuePlaceholder})`
54✔
255
    }
256
  } else {
257
    statement = [namePlaceholder, operator, valuePlaceholder].join(' ')
106✔
258
  }
259

260
  const attributeValues: Attributes<any> = {}
188✔
261
  if (hasValue) {
188✔
262
    let attribute: Attribute | null
263
    if (operator === 'contains' || operator === 'not_contains') {
160✔
264
      attribute = toDbOne(values[0], alterCollectionPropertyMetadataForSingleItem(propertyMetadata))
32✔
265
      validateAttributeType(`${operator} condition`, attribute, 'N', 'S', 'B')
32✔
266
    } else {
267
      attribute = toDbOne(values[0], propertyMetadata)
128✔
268
      switch (operator) {
128✔
269
        case 'begins_with':
270
          validateAttributeType(`${operator} condition`, attribute, 'S', 'B')
18✔
271
          break
18✔
272
        case '<':
273
        case '<=':
274
        case '>':
275
        case '>=':
276
          validateAttributeType(`${operator} condition`, attribute, 'N', 'S', 'B')
62✔
277
          break
62✔
278
      }
279
    }
280

281
    if (attribute) {
160✔
282
      attributeValues[valuePlaceholder] = attribute
160✔
283
    }
284
  }
285

286
  return {
188✔
287
    statement,
288
    attributeNames,
289
    attributeValues,
290
  }
291
}
292

293
/**
294
 * Every operator requires a predefined arity of parameters, this method checks for the correct arity and throws an Error
295
 * if not correct
296
 *
297
 * @param operator
298
 * @param values The values which will be applied to the operator function implementation, not every operator requires values
299
 * @throws {Error} error Throws an error if the amount of values won't match the operator function parameter arity or
300
 * the given values is not an array
301
 * @hidden
302
 */
303
function validateForOperator(operator: ConditionOperator, values?: any[]) {
304
  validateArity(operator, values)
210✔
305

306
  /*
307
   * validate values if operator supports values
308
   */
309
  if (!isFunctionOperator(operator) || (isFunctionOperator(operator) && !isNoParamFunctionOperator(operator))) {
206✔
310
    if (values && Array.isArray(values) && values.length) {
178!
311
      validateValues(operator, values)
178✔
312
    } else {
313
      throw new Error(
×
314
        dynamicTemplate(ERR_ARITY_DEFAULT, { parameterArity: operatorParameterArity(operator), operator }),
315
      )
316
    }
317
  }
318
}
319

320
// tslint:disable:no-invalid-template-strings
321
/*
322
 * error messages for arity issues
323
 */
324
/**
325
 * @hidden
326
 */
327
export const ERR_ARITY_IN =
84✔
328
  'expected ${parameterArity} value(s) for operator ${operator}, this is not the right amount of method parameters for this operator (IN operator requires one value of array type)'
329

330
/**
331
 * @hidden
332
 */
333
export const ERR_ARITY_DEFAULT =
84✔
334
  'expected ${parameterArity} value(s) for operator ${operator}, this is not the right amount of method parameters for this operator'
335

336
// tslint:enable:no-invalid-template-strings
337
/**
338
 * @hidden
339
 */
340
function validateArity(operator: ConditionOperator, values?: any[]) {
341
  if (values === null || values === undefined) {
210!
UNCOV
342
    if (isFunctionOperator(operator) && !isNoParamFunctionOperator(operator)) {
×
343
      // the operator needs some values to work
UNCOV
344
      throw new Error(
×
345
        dynamicTemplate(ERR_ARITY_DEFAULT, { parameterArity: operatorParameterArity(operator), operator }),
346
      )
347
    }
348
  } else if (values && Array.isArray(values)) {
210✔
349
    const parameterArity = operatorParameterArity(operator)
210✔
350
    // check for correct amount of values
351
    if (values.length !== parameterArity) {
210✔
352
      switch (operator) {
4✔
353
        case 'IN':
354
          throw new Error(dynamicTemplate(ERR_ARITY_IN, { parameterArity, operator }))
2✔
355
        default:
356
          throw new Error(dynamicTemplate(ERR_ARITY_DEFAULT, { parameterArity, operator }))
2✔
357
      }
358
    }
359
  }
360
}
361

362
/*
363
 * error message for wrong operator values
364
 */
365
// tslint:disable:no-invalid-template-strings
366
/**
367
 * @hidden
368
 */
369
export const ERR_VALUES_BETWEEN_TYPE =
84✔
370
  'both values for operator BETWEEN must have the same type, got ${value1} and ${value2}'
371
/**
372
 * @hidden
373
 */
374
export const ERR_VALUES_IN = 'the provided value for IN operator must be an array'
84✔
375

376
// tslint:enable:no-invalid-template-strings
377

378
/**
379
 * Every operator has some constraints about the values it supports, this method makes sure everything is fine for given
380
 * operator and values
381
 * @hidden
382
 */
383
function validateValues(operator: ConditionOperator, values: any[]) {
384
  // some additional operator dependent validation
385
  switch (operator) {
178✔
386
    case 'BETWEEN':
387
      // values must be the same type
388
      if (typeOf(values[0]) !== typeOf(values[1])) {
12✔
389
        throw new Error(
2✔
390
          dynamicTemplate(ERR_VALUES_BETWEEN_TYPE, { value1: typeOf(values[0]), value2: typeOf(values[1]) }),
391
        )
392
      }
393
      break
10✔
394
    case 'IN':
395
      if (!Array.isArray(values[0])) {
6✔
396
        throw new Error(ERR_VALUES_IN)
2✔
397
      }
398
  }
399
}
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

© 2026 Coveralls, Inc