@jspreddy/torq 0.1.31

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.
@@ -0,0 +1,574 @@
1
+ import _ from 'lodash';
2
+ import { reserved } from './dynamo_reserved_words';
3
+ import assert from 'assert';
4
+ import { Index, Table } from './Structure';
5
+
6
+ /**
7
+ * Type of attributes accepted by dynamo db.
8
+ */
9
+ export enum DdbType {
10
+ String = 'S',
11
+ StringSet = 'SS',
12
+ Number = 'N',
13
+ NumberSet = 'NS',
14
+ Binary = 'B',
15
+ BinarySet = 'BS',
16
+ Boolean = 'BOOL',
17
+ Null = 'NULL',
18
+ List = 'L',
19
+ Map = 'M',
20
+ }
21
+
22
+ type DynamoValue = string | number | boolean;
23
+
24
+ type BetweenValues = {
25
+ start: DynamoValue,
26
+ end: DynamoValue,
27
+ };
28
+
29
+ export enum Operation {
30
+ Eq = '=',
31
+ NotEq = '<>',
32
+ Gt = '>',
33
+ GtEq = '>=',
34
+ Lt = '<',
35
+ LtEq = '<=',
36
+ }
37
+
38
+ type SizeValue = {
39
+ val: DynamoValue,
40
+ op: Operation,
41
+ };
42
+
43
+ type Condition = {
44
+ key: string;
45
+ val?: DynamoValue | BetweenValues | DdbType | SizeValue;
46
+ type: string;
47
+ actualName?: string,
48
+ };
49
+
50
+ type ProjectionExpressionObj = { ProjectionExpression: string | undefined };
51
+ type KeyConditionExpressionObj = { KeyConditionExpression: string | undefined };
52
+ type FilterExpressionObj = { FilterExpression: string | undefined };
53
+ type ExpressionAttributeValuesObj = { ExpressionAttributeValues: object | undefined };
54
+ type ExpressionAttributeNamesObj = { ExpressionAttributeNames: object | undefined };
55
+
56
+ type Replacements = {
57
+ keys: Record<string, string>;
58
+ vals: Record<string, DynamoValue>;
59
+ };
60
+
61
+ type RawFilter = {
62
+ condition: string;
63
+ replacements: Replacements;
64
+ } | undefined;
65
+
66
+ export class Query {
67
+ static DEFAULT_LIMIT = 25;
68
+
69
+ private _tableName: string;
70
+ private _hashKey: string;
71
+ private _rangeKey: string | undefined;
72
+
73
+ private _mode: 'select' | 'count' | 'scan' | undefined;
74
+
75
+ private _selections: string[];
76
+ private _keys: Array<Condition>;
77
+ private _filters: Array<Condition>;
78
+ private _rawFilters: Array<RawFilter>;
79
+
80
+ private _index: Index | undefined;
81
+ private _scanForward: boolean | undefined;
82
+ private _limit: number = Query.DEFAULT_LIMIT;
83
+ private _count: boolean = false;
84
+ private _startAfter: string | number | object | undefined;
85
+ private _consumedCapacity: string | undefined;
86
+
87
+ get state() {
88
+ return {
89
+ tableName: this._tableName,
90
+ hashKey: this._hashKey,
91
+ rangeKey: this._rangeKey,
92
+
93
+ mode: this._mode,
94
+
95
+ selections: this._selections,
96
+ keys: this._keys,
97
+ filters: this._filters,
98
+
99
+ index: this._index,
100
+ scanForward: this._scanForward,
101
+ limit: this._limit,
102
+ count: this._count,
103
+ startAfter: this._startAfter,
104
+ consumedCapacity: this._consumedCapacity,
105
+ };
106
+ }
107
+
108
+ constructor(table: Table) {
109
+ this._tableName = table.name;
110
+ this._hashKey = table.hashKey;
111
+ this._rangeKey = table.rangeKey;
112
+
113
+ // initialize here so that each query obj has its own filter list.
114
+ this._filters = [];
115
+ this._keys = [];
116
+ this._selections = [];
117
+ this._rawFilters = [];
118
+ }
119
+
120
+ select(cols: string[]) {
121
+ throwIfModeExists(this._mode);
122
+ this._selections = cols;
123
+ this._mode = 'select';
124
+ return this;
125
+ }
126
+
127
+ scan(cols: string[]) {
128
+ throwIfModeExists(this._mode);
129
+ this._selections = cols;
130
+ this._mode = 'scan';
131
+ return this;
132
+ }
133
+
134
+ count() {
135
+ throwIfModeExists(this._mode);
136
+ this._count = true;
137
+ this._mode = 'count';
138
+ return this;
139
+ }
140
+
141
+ get where() {
142
+ assert(this._mode !== 'scan', 'Query.where: Cannot use "where" clause with scan(), use "filter" instead.');
143
+
144
+ const pushRangeKey = (val: Condition['val'], type: string) => {
145
+ if (_.isNil(this._index)) {
146
+ assert(
147
+ _.isString(this._rangeKey) && _.size(this._rangeKey) > 0,
148
+ 'Query.where.range: Table does not have a rangeKey',
149
+ );
150
+ this._keys.push({ key: this._rangeKey, val: val, type: type });
151
+ return;
152
+ }
153
+ assert(
154
+ _.isString(this._index.rangeKey) && _.size(this._index.rangeKey) > 0,
155
+ 'Query.where.range: Provided Index does not have a rangeKey',
156
+ );
157
+ this._keys.push({ key: this._index.rangeKey, val: val, type: type });
158
+ };
159
+ const whereSelectors = {
160
+ hash: {
161
+ eq: (val: string): Query => {
162
+ if (_.isNil(this._index)) {
163
+ this._keys.push({ key: this._hashKey, val: val, type: 'hash-eq' });
164
+ return this;
165
+ }
166
+ assert(
167
+ _.isString(this._index?.hashKey) && _.size(this._index.hashKey) > 0,
168
+ 'Query.where.hash: Provided Index does not have a hashKey',
169
+ );
170
+ this._keys.push({ key: this._index.hashKey, val: val, type: 'hash-eq' });
171
+ return this;
172
+ }
173
+ },
174
+ range: {
175
+ eq: (val: DynamoValue): Query => {
176
+ pushRangeKey(val, 'eq');
177
+ return this;
178
+ },
179
+ beginsWith: (val: DynamoValue): Query => {
180
+ pushRangeKey(val, 'begins_with');
181
+ return this;
182
+ },
183
+ gt: (val: DynamoValue): Query => {
184
+ pushRangeKey(val, 'gt');
185
+ return this;
186
+ },
187
+ gtEq: (val: DynamoValue): Query => {
188
+ pushRangeKey(val, 'gtEq');
189
+ return this;
190
+ },
191
+ lt: (val: DynamoValue): Query => {
192
+ pushRangeKey(val, 'lt');
193
+ return this;
194
+ },
195
+ ltEq: (val: DynamoValue): Query => {
196
+ pushRangeKey(val, 'ltEq');
197
+ return this;
198
+ },
199
+ between: (start: DynamoValue, end: DynamoValue): Query => {
200
+ pushRangeKey({ start, end }, 'between');
201
+ return this;
202
+ },
203
+ },
204
+ };
205
+ return whereSelectors;
206
+ }
207
+
208
+ get filter() {
209
+ const filterConditions = {
210
+ eq: (key: string, val: DynamoValue): Query => {
211
+ this._filters.push({ key, val, type: 'eq' });
212
+ return this;
213
+ },
214
+ notEq: (key: string, val: DynamoValue): Query => {
215
+ this._filters.push({ key, val, type: 'notEq' });
216
+ return this;
217
+ },
218
+ gt: (key: string, val: DynamoValue): Query => {
219
+ this._filters.push({ key, val, type: 'gt' });
220
+ return this;
221
+ },
222
+ gtEq: (key: string, val: DynamoValue): Query => {
223
+ this._filters.push({ key, val, type: 'gtEq' });
224
+ return this;
225
+ },
226
+ lt: (key: string, val: DynamoValue): Query => {
227
+ this._filters.push({ key, val, type: 'lt' });
228
+ return this;
229
+ },
230
+ ltEq: (key: string, val: DynamoValue): Query => {
231
+ this._filters.push({ key, val, type: 'ltEq' });
232
+ return this;
233
+ },
234
+ beginsWith: (key: string, val: DynamoValue): Query => {
235
+ this._filters.push({ key, val, type: 'begins_with' });
236
+ return this;
237
+ },
238
+ attributeExists: (key: string): Query => {
239
+ this._filters.push({ key, type: 'attribute_exists' });
240
+ return this;
241
+ },
242
+ attributeNotExists: (key: string): Query => {
243
+ this._filters.push({ key, type: 'attribute_not_exists' });
244
+ return this;
245
+ },
246
+ attributeType: (key: string, val: DdbType): Query => {
247
+ this._filters.push({ key, val, type: 'attribute_type' });
248
+ return this;
249
+ },
250
+ contains: (key: string, val: DynamoValue): Query => {
251
+ this._filters.push({ key, val, type: 'contains' });
252
+ return this;
253
+ },
254
+ size: (key: string, op: Operation, val: DynamoValue): Query => {
255
+ this._filters.push({
256
+ key,
257
+ val: {
258
+ val,
259
+ op,
260
+ },
261
+ type: 'size',
262
+ });
263
+ return this;
264
+ },
265
+ between: (key: string, start: DynamoValue, end: DynamoValue): Query => {
266
+ this._filters.push({ key, val: { start, end }, type: 'between' });
267
+ return this;
268
+ },
269
+ raw: (filterCondition: string, replacements: Replacements): Query => {
270
+ this._rawFilters.push({
271
+ condition: filterCondition,
272
+ replacements,
273
+ });
274
+ return this;
275
+ },
276
+ };
277
+ return filterConditions;
278
+ }
279
+
280
+ using(index: Index, scanForward: boolean | undefined = undefined): Query {
281
+ assert(
282
+ typeof scanForward === 'boolean' || _.isNil(scanForward),
283
+ 'Query.using(): scanForward must be a boolean or undefined',
284
+ );
285
+
286
+ this._index = index;
287
+ this._scanForward = scanForward;
288
+ return this;
289
+ }
290
+
291
+ limit(l: number): Query {
292
+ this._limit = l ?? Query.DEFAULT_LIMIT;
293
+ return this;
294
+ }
295
+
296
+ startAfter(lastEvaluatedKey: string | number | object | undefined) {
297
+ this._startAfter = lastEvaluatedKey;
298
+ return this;
299
+ }
300
+
301
+ withConsumedCapacity(capacityType: 'INDEXES' | 'TOTAL' | 'NONE' = 'TOTAL') {
302
+ assert(
303
+ capacityType === 'INDEXES' || capacityType === 'TOTAL' || capacityType === 'NONE',
304
+ 'Query.withConsumedCapacity(): capacity type must be INDEXES, TOTAL, or NONE',
305
+ );
306
+ if (capacityType === 'NONE') {
307
+ return this;
308
+ }
309
+ this._consumedCapacity = capacityType;
310
+ return this;
311
+ }
312
+
313
+ toDynamo(): object {
314
+ const [keyCond, keyAttribVals, keyAttribNames] = formatKeyCondition(this._keys);
315
+ const [filterCond, filterAttribVals, filterAttribNames] = formatFilterCondition(this._filters, this._rawFilters);
316
+ const [projection, projectionAttribNames] = formatProjectionExpression(this._selections);
317
+
318
+ return _.omitBy({
319
+ TableName: this._tableName,
320
+ Select: this._count ? 'COUNT' : undefined,
321
+ ...projection,
322
+ ...keyCond,
323
+ ...filterCond,
324
+ ..._.merge(keyAttribNames, filterAttribNames, projectionAttribNames),
325
+ ..._.merge(keyAttribVals, filterAttribVals),
326
+ Limit: this._limit,
327
+ IndexName: this._index?.name,
328
+ ScanIndexForward: this._scanForward,
329
+ ExclusiveStartKey: this._startAfter,
330
+ ReturnConsumedCapacity: this._consumedCapacity,
331
+ }, _.isNil);
332
+ }
333
+ }
334
+
335
+ const isReserved = (name: string): boolean => {
336
+ // Does the upper cased "name" exists in the reserved words list.
337
+ return _.indexOf(reserved, _.toUpper(name)) != -1;
338
+ }
339
+
340
+ const replaceReservedNames = (conditions: Array<Condition>): Array<Condition> => {
341
+ return _.map(conditions, (cond) => {
342
+
343
+ if (isReserved(cond.key)) {
344
+ return {
345
+ ...cond,
346
+ key: `#${cond.key}`,
347
+ actualName: cond.key,
348
+ };
349
+ }
350
+
351
+ if (_.startsWith(cond.key, "_")) {
352
+ return {
353
+ ...cond,
354
+ key: `#${cond.key}`,
355
+ actualName: cond.key,
356
+ };
357
+ }
358
+
359
+ return cond;
360
+ });
361
+ };
362
+
363
+ const formatKeyCondition = (conditions: Array<Condition>): [KeyConditionExpressionObj, ExpressionAttributeValuesObj, ExpressionAttributeNamesObj] => {
364
+ const updatedConditions = replaceReservedNames(conditions);
365
+
366
+ const conditionParts: string[] = [];
367
+ const attribVals = {};
368
+ const attribNames = {};
369
+
370
+ _.each(updatedConditions, (cond) => {
371
+ const { key, val, type, actualName } = cond;
372
+ const valRef = `:${_.trim(key, '#')}`;
373
+
374
+ switch (type) {
375
+ case "hash-eq":
376
+ case "eq":
377
+ conditionParts.push(`${key} = ${valRef}`);
378
+ _.set(attribVals, valRef, val);
379
+ break;
380
+
381
+ case "gt":
382
+ conditionParts.push(`${key} > ${valRef}`);
383
+ _.set(attribVals, valRef, val);
384
+ break;
385
+
386
+ case "gtEq":
387
+ conditionParts.push(`${key} >= ${valRef}`);
388
+ _.set(attribVals, valRef, val);
389
+ break;
390
+
391
+ case "lt":
392
+ conditionParts.push(`${key} < ${valRef}`);
393
+ _.set(attribVals, valRef, val);
394
+ break;
395
+
396
+ case "ltEq":
397
+ conditionParts.push(`${key} <= ${valRef}`);
398
+ _.set(attribVals, valRef, val);
399
+ break;
400
+
401
+ case "between": {
402
+ const valRefStart = `${valRef}_start`;
403
+ const valRefEnd = `${valRef}_end`;
404
+ const between = val as BetweenValues;
405
+ conditionParts.push(`${key} BETWEEN ${valRefStart} AND ${valRefEnd}`);
406
+ _.set(attribVals, valRefStart, between.start);
407
+ _.set(attribVals, valRefEnd, between.end);
408
+ break;
409
+ }
410
+
411
+ case "begins_with":
412
+ conditionParts.push(`begins_with(${key}, ${valRef})`);
413
+ _.set(attribVals, valRef, val);
414
+ break;
415
+ }
416
+
417
+ if (actualName) {
418
+ _.set(attribNames, key, actualName);
419
+ }
420
+ });
421
+
422
+ return [
423
+ { KeyConditionExpression: _.isEmpty(conditionParts) ? undefined : _.join(conditionParts, " and ") },
424
+ { ExpressionAttributeValues: _.isEmpty(attribVals) ? undefined : attribVals },
425
+ { ExpressionAttributeNames: _.isEmpty(attribNames) ? undefined : attribNames },
426
+ ];
427
+ };
428
+
429
+ const formatFilterCondition = (filters: Array<Condition>, rawFilters: Array<RawFilter>): [FilterExpressionObj, ExpressionAttributeValuesObj, ExpressionAttributeNamesObj] => {
430
+ const updatedFilters = replaceReservedNames(filters);
431
+
432
+ const filterParts: string[] = [];
433
+ const attribVals = {};
434
+ const attribNames = {};
435
+ const usedValRefs = new Set<string>();
436
+
437
+ _.each(updatedFilters, f => {
438
+ let valRef = `:${_.trim(f.key, '#')}`;
439
+ // Add numeric suffix if valRef is already used
440
+ if (usedValRefs.has(valRef)) {
441
+ let counter = 1;
442
+ while (usedValRefs.has(`${valRef}_${counter}`)) {
443
+ counter++;
444
+ }
445
+ valRef = `${valRef}_${counter}`;
446
+ }
447
+ usedValRefs.add(valRef);
448
+
449
+ switch (f.type) {
450
+ case 'eq':
451
+ _.set(attribVals, valRef, f.val);
452
+ filterParts.push(`${f.key} = ${valRef}`);
453
+ break;
454
+
455
+ case 'notEq':
456
+ _.set(attribVals, valRef, f.val);
457
+ filterParts.push(`${f.key} <> ${valRef}`);
458
+ break;
459
+
460
+ case 'gt':
461
+ _.set(attribVals, valRef, f.val);
462
+ filterParts.push(`${f.key} > ${valRef}`);
463
+ break;
464
+
465
+ case 'gtEq':
466
+ _.set(attribVals, valRef, f.val);
467
+ filterParts.push(`${f.key} >= ${valRef}`);
468
+ break;
469
+
470
+ case 'lt':
471
+ _.set(attribVals, valRef, f.val);
472
+ filterParts.push(`${f.key} < ${valRef}`);
473
+ break;
474
+
475
+ case 'ltEq':
476
+ _.set(attribVals, valRef, f.val);
477
+ filterParts.push(`${f.key} <= ${valRef}`);
478
+ break;
479
+
480
+ case 'begins_with':
481
+ _.set(attribVals, valRef, f.val);
482
+ filterParts.push(`begins_with(${f.key}, ${valRef})`);
483
+ break;
484
+
485
+ case 'attribute_exists':
486
+ _.set(attribVals, valRef, f.val);
487
+ filterParts.push(`attribute_exists(${f.key})`);
488
+ break;
489
+
490
+ case 'attribute_not_exists':
491
+ _.set(attribVals, valRef, f.val);
492
+ filterParts.push(`attribute_not_exists(${f.key})`);
493
+ break;
494
+
495
+ case 'attribute_type':
496
+ _.set(attribVals, valRef, f.val);
497
+ filterParts.push(`attribute_type(${f.key}, ${valRef})`);
498
+ break;
499
+
500
+ case 'contains':
501
+ _.set(attribVals, valRef, f.val);
502
+ filterParts.push(`contains(${f.key}, ${valRef})`);
503
+ break;
504
+
505
+ case 'size': {
506
+ const sizeVal = f.val as SizeValue;
507
+ const newValRef = `:size_${_.trim(valRef, ':')}`;
508
+ _.set(attribVals, newValRef, sizeVal.val);
509
+ filterParts.push(`size(${f.key}) ${sizeVal.op} ${newValRef}`);
510
+ break;
511
+ }
512
+
513
+ case 'between': {
514
+ const valRefStart = `${valRef}_start`;
515
+ const valRefEnd = `${valRef}_end`;
516
+ const between = f.val as BetweenValues;
517
+ filterParts.push(`${f.key} BETWEEN ${valRefStart} AND ${valRefEnd}`);
518
+ _.set(attribVals, valRefStart, between.start);
519
+ _.set(attribVals, valRefEnd, between.end);
520
+ break;
521
+ }
522
+ }
523
+
524
+ if (f.actualName) {
525
+ _.set(attribNames, f.key, f.actualName);
526
+ }
527
+ });
528
+
529
+ let filterExp = _.join(filterParts, ' and ');
530
+
531
+ if (rawFilters.length > 0) {
532
+ const rawFilterCondition = _.chain(rawFilters).map(f => f ? `(${f.condition})` : undefined).compact().join(' and ').value();
533
+ if (_.isEmpty(filterExp)) {
534
+ filterExp = rawFilterCondition;
535
+ }
536
+ else {
537
+ filterExp = _.join([filterExp, rawFilterCondition], ' and ');
538
+ }
539
+ _.merge(attribVals, _.chain(rawFilters).map(f => f?.replacements.vals).compact().reduce(_.merge, {}).value());
540
+ _.merge(attribNames, _.chain(rawFilters).map(f => f?.replacements.keys).compact().reduce(_.merge, {}).value());
541
+ }
542
+
543
+ return [
544
+ { FilterExpression: _.isEmpty(filterExp) ? undefined : filterExp },
545
+ { ExpressionAttributeValues: _.isEmpty(attribVals) ? undefined : attribVals },
546
+ { ExpressionAttributeNames: _.isEmpty(attribNames) ? undefined : attribNames },
547
+ ];
548
+ };
549
+
550
+ const formatProjectionExpression = (proj: Array<string>): [ProjectionExpressionObj, ExpressionAttributeNamesObj] => {
551
+ const attribNames = {};
552
+
553
+ const projection = _.map(proj, (col) => {
554
+ const attribRef = `#${_.trim(col)}`;
555
+ if (isReserved(col)) {
556
+ _.set(attribNames, attribRef, col);
557
+ return attribRef;
558
+ }
559
+ if (_.startsWith(col, "_")) {
560
+ _.set(attribNames, attribRef, col);
561
+ return attribRef;
562
+ }
563
+ return col;
564
+ });
565
+
566
+ return [
567
+ { ProjectionExpression: _.isEmpty(projection) ? undefined : _.join(projection, ", ") },
568
+ { ExpressionAttributeNames: _.isEmpty(attribNames) ? undefined : attribNames },
569
+ ];
570
+ };
571
+
572
+ function throwIfModeExists(mode: string | undefined) {
573
+ assert(_.isEmpty(mode), 'Query: Cannot use more than one mode (select, count, scan) at the same time.');
574
+ }
@@ -0,0 +1,57 @@
1
+ import _ from 'lodash';
2
+ import assert from 'assert';
3
+
4
+ export class Table {
5
+ private _name: string;
6
+ private _hashKey: string;
7
+ private _rangeKey: string | undefined;
8
+
9
+ constructor(name: string, hashKey: string, rangeKey: string | undefined = undefined) {
10
+ assert(_.isString(name) && _.size(name) > 0, 'Table.constructor(): name must be provided');
11
+ assert(_.isString(hashKey) && _.size(hashKey) > 0, 'Table.constructor(): hashKey must be provided');
12
+ assert(_.isNil(rangeKey) || (_.isString(rangeKey) && _.size(rangeKey) > 0), 'Table.constructor(): rangeKey is invalid');
13
+
14
+ this._name = name;
15
+ this._hashKey = hashKey;
16
+ this._rangeKey = rangeKey;
17
+ }
18
+
19
+ get name() {
20
+ return this._name;
21
+ }
22
+
23
+ get hashKey() {
24
+ return this._hashKey;
25
+ }
26
+
27
+ get rangeKey() {
28
+ return this._rangeKey;
29
+ }
30
+ }
31
+
32
+ export class Index {
33
+ private _name: string;
34
+ private _hashKey: string;
35
+ private _rangeKey: string | undefined;
36
+
37
+ constructor(name: string, hashKey: string, rangeKey: string | undefined) {
38
+ assert(_.isString(name) && _.size(name) > 0, 'Index.constructor(): name must be provided');
39
+ assert(_.isString(hashKey) && _.size(hashKey) > 0, 'Index.constructor(): hashKey must be provided');
40
+
41
+ this._name = name;
42
+ this._hashKey = hashKey;
43
+ this._rangeKey = rangeKey;
44
+ }
45
+
46
+ get name() {
47
+ return this._name;
48
+ }
49
+
50
+ get hashKey() {
51
+ return this._hashKey;
52
+ }
53
+
54
+ get rangeKey() {
55
+ return this._rangeKey;
56
+ }
57
+ }