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

eresearchqut / ddb-repository / 26010109112

18 May 2026 02:21AM UTC coverage: 91.449% (-0.008%) from 91.457%
26010109112

push

github

ryan-bennett
[Repo Assist] fix: handle empty updates/remove in updateItem, preserve key attributes in batchGetItems

148 of 180 branches covered (82.22%)

Branch coverage included in aggregate %.

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

2 existing lines in 1 file now uncovered.

237 of 241 relevant lines covered (98.34%)

174.66 hits per line

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

88.61
/src/DynamoDbRepository.ts
1
import {
2✔
2
    BatchGetItemCommand,
3
    BatchGetItemCommandInput,
4
    DeleteItemCommand,
5
    DynamoDBClient,
6
    GetItemCommand,
7
    paginateQuery,
8
    PutItemCommand,
9
    QueryCommandInput,
10
    ReturnConsumedCapacity,
11
    ReturnValue,
12
    UpdateItemCommand,
13
} from "@aws-sdk/client-dynamodb";
14
import {marshall, unmarshall, NativeAttributeValue} from "@aws-sdk/util-dynamodb";
2✔
15
import {replace, uniqWith, isEqual, pickBy} from "lodash";
2✔
16

17
export enum FilterOperator {
2✔
18
    EQUALS = "=",
2✔
19
    NOT_EQUALS = "<>",
2✔
20
    GREATER_THAN_OR_EQUALS = ">=",
2✔
21
    GREATER_THAN = ">",
2✔
22
    LESS_THAN = "<",
2✔
23
    LESS_THAN_OR_EQUALS = "<=",
2✔
24
    IN = "IN",
2✔
25
    BETWEEN = "BETWEEN",
2✔
26
}
27

28
export interface FilterExpression {
29
    attribute: string;
30
    value:
31
        | string
32
        | number
33
        | boolean
34
        | Array<string | number>
35
        | [string, string]
36
        | [number, number];
37
    operator: FilterOperator;
38
    negate?: boolean;
39
}
40

41
export interface FilterableQuery {
42
    filterExpressions: Array<FilterExpression>;
43
}
44

45
export interface ProjectedQuery {
46
    projectedAttributes: string[];
47
}
48

49
export interface IndexedQuery {
50
    index: string;
51
}
52

53
export interface Query extends Partial<FilterableQuery>, Partial<ProjectedQuery>, Partial<IndexedQuery> {
54
    [key: string]: unknown;
55
    filterExpressions?: Array<FilterExpression>;
56
    projectedAttributes?: string[];
57
    index?: string;
58
    sortOrder?: "ASC" | "DESC";
59
    limit?: number
60
}
61

62
const marshallKey = (key: unknown) =>
2✔
63
    marshall(key as Record<string, NativeAttributeValue>, {removeUndefinedValues: true});
364✔
64

65
const expressionAttributeKey = (key: string) => replace(key, /-/g, "_");
728✔
66

67
const mapInKeys = (filterExpression: FilterExpression) =>
2✔
68
    Array.isArray(filterExpression.value)
7!
69
        ? filterExpression.value.map(
70
            (_, index) => `:${expressionAttributeKey(filterExpression.attribute)}${index}`,
14✔
71
        )
72
        : `:${expressionAttributeKey(filterExpression.attribute)}`;
73

74
const mapFilterExpression = (filterExpression: FilterExpression) => {
2✔
75
    switch (filterExpression.operator) {
40✔
76
        case FilterOperator.IN:
77
            return (
7✔
78
                `#${expressionAttributeKey(filterExpression.attribute)} ${filterExpression.operator} ` +
79
                `(${mapInKeys(filterExpression)})`
80
            );
81
        case FilterOperator.BETWEEN:
82
            return (
7✔
83
                `#${expressionAttributeKey(filterExpression.attribute)} ${filterExpression.operator} ` +
84
                `:${expressionAttributeKey(filterExpression.attribute)}0 AND :${expressionAttributeKey(filterExpression.attribute)}1`
85
            );
86
        default:
87
            return (
26✔
88
                `#${expressionAttributeKey(filterExpression.attribute)} ${filterExpression.operator} ` +
89
                `:${expressionAttributeKey(filterExpression.attribute)}`
90
            );
91
    }
92
};
93

94
const mapFilterExpressions = (
2✔
95
    filterExpressions: Array<FilterExpression>,
96
) =>
97
    filterExpressions
37✔
98
        .map((filterExpression) =>
99
            filterExpression.negate
40✔
100
                ? `NOT ${mapFilterExpression(filterExpression)}`
101
                : mapFilterExpression(filterExpression),
102
        )
103
        .join(" AND ");
104

105
const mapFilterExpressionValues = (
2✔
106
    filterExpression: FilterExpression,
107
): Record<string, string | number | boolean> =>
108
    Array.isArray(filterExpression.value)
40✔
109
        ? filterExpression.value.reduce(
110
            (reduction, value, index) => ({
28✔
111
                ...reduction,
112
                [`:${expressionAttributeKey(filterExpression.attribute)}${index}`]: value,
113
            }),
114
            Object.assign({}),
115
        )
116
        : {
117
            [`:${expressionAttributeKey(filterExpression.attribute)}`]:
118
            filterExpression.value,
119
        };
120

121
const paginate = <T>(array: Array<T>, pageSize: number) => {
2✔
122
    return array.reduce((acc, val, i) => {
18✔
123
        const idx = Math.floor(i / pageSize)
309✔
124
        const page = acc[idx] || (acc[idx] = [])
309✔
125
        page.push(val)
309✔
126
        return acc
309✔
127
    }, [] as Array<Array<T>>);
128
}
129

130
export interface DynamoDbRepositoryOptions {
131
    client: DynamoDBClient;
132
    tableName: string;
133
    hashKey: string;
134
    rangeKey?: string;
135
    returnConsumedCapacity?: ReturnConsumedCapacity;
136
}
137

138
export class DynamoDbRepository<K, T> {
2✔
139
    private readonly dynamoDBClient: DynamoDBClient;
140
    private readonly tableName: string;
141
    private readonly hashKey: string;
142
    private readonly rangKey?: string;
143
    private readonly returnConsumedCapacity: ReturnConsumedCapacity | undefined;
144

145
    constructor(options: DynamoDbRepositoryOptions) {
146
        this.dynamoDBClient = options.client;
5✔
147
        this.tableName = options.tableName;
5✔
148
        this.hashKey = options.hashKey;
5✔
149
        this.rangKey = options.rangeKey;
5✔
150
        this.returnConsumedCapacity = options.returnConsumedCapacity ?? ReturnConsumedCapacity.TOTAL;
5!
151
    }
152

153
    getItem = async (key: K): Promise<T | undefined> => {
24✔
154
        return this.dynamoDBClient
24✔
155
            .send(
156
                new GetItemCommand({
157
                    TableName: this.tableName,
158
                    Key: marshallKey(key),
159
                    ReturnConsumedCapacity: this.returnConsumedCapacity,
160
                }),
161
            )
162
            .then((result) =>
163
                result.Item ? unmarshall(result.Item) as T : undefined,
24✔
164
            )
165
    };
166

167
    putItem = async (key: K, record: T): Promise<T> => {
753✔
168
        const Item = marshall({...record, ...key} as Record<string, NativeAttributeValue>, {removeUndefinedValues: true});
753✔
169
        return this.dynamoDBClient
753✔
170
            .send(
171
                new PutItemCommand({
172
                    TableName: this.tableName,
173
                    ReturnConsumedCapacity: this.returnConsumedCapacity,
174
                    Item,
175
                }),
176
            )
177
            .then(() => unmarshall(Item) as T);
753✔
178
    };
179

180
    deleteItem = async (key: K): Promise<T | undefined> => {
23✔
181
        return this.dynamoDBClient.send(new DeleteItemCommand({
23✔
182
            TableName: this.tableName,
183
            Key: marshallKey(key),
184
            ReturnValues: ReturnValue.ALL_OLD,
185
            ReturnConsumedCapacity: this.returnConsumedCapacity,
186
        })).then((result) => result.Attributes ?
23✔
187
            unmarshall(result.Attributes) as T : undefined);
188
    };
189

190

191
    updateItem = async (
5✔
192
        key: K,
193
        updates: Partial<T>,
194
        remove?: string[],
195
    ): Promise<T | undefined> => {
9✔
196
        const filteredUpdateEntries = Object.entries(updates).filter(([, value]) => value !== undefined);
9✔
197
        const hasUpdates = filteredUpdateEntries.length > 0;
9✔
198
        if (!hasUpdates && !remove?.length) {
9✔
199
            return this.getItem(key);
1✔
200
        }
201
        const setAttributesExpression = hasUpdates ? `SET ${filteredUpdateEntries
8✔
202
            .map(
203
                ([key]) =>
204
                    `#${expressionAttributeKey(key)} = :${expressionAttributeKey(key)}`,
6✔
205
            )
206
            .join(", ")}` : '';
207
        const removeAttributesExpression = remove?.length
8✔
208
            ? ` REMOVE ${remove.map((key) => `#${expressionAttributeKey(key)}`).join(", ")}`
4✔
209
            : "";
210
        const removeAttributeNames = remove?.length
8✔
211
            ? remove.reduce(
212
                (acc, key) => ({
4✔
213
                    ...acc,
214
                    [`#${expressionAttributeKey(key)}`]: key,
215
                }),
216
                {} as Record<string, string>,
217
            )
218
            : {};
219
        const updateItemCommandInput = {
8✔
220
            TableName: this.tableName,
221
            Key: marshallKey(key),
222
            UpdateExpression: `${setAttributesExpression}${removeAttributesExpression}`,
223
            ExpressionAttributeNames: filteredUpdateEntries
224
                .reduce(
225
                    (acc, [key]) => ({
6✔
226
                        ...acc,
227
                        [`#${expressionAttributeKey(key)}`]: key,
228
                    }),
229
                    Object.assign(
230
                        removeAttributeNames,
231
                    ),
232
                ) as Record<string, string>,
233
            ExpressionAttributeValues: hasUpdates ? marshall(
8✔
234
                filteredUpdateEntries.reduce(
235
                    (acc, [key, value]) => ({
6✔
236
                        ...acc,
237
                        [`:${expressionAttributeKey(key)}`]: value,
238
                    }),
239
                    {} as Record<string, NativeAttributeValue>,
240
                ),
241
                {removeUndefinedValues: true},
242
            ) : undefined,
243
            ReturnConsumedCapacity: this.returnConsumedCapacity,
244
        };
245
        return this.dynamoDBClient
8✔
246
            .send(new UpdateItemCommand({...updateItemCommandInput, ReturnValues: 'ALL_NEW'}))
247
            .then((result) => result.Attributes ? unmarshall(result.Attributes) as T : undefined);
8!
248
    };
249

250

251
    getItems = async (
5✔
252
        query: Query
253
    ): Promise<Array<T> | undefined> => {
122✔
254
        const {index, filterExpressions, projectedAttributes, limit, sortOrder,...keys} = query;
122✔
255
        const KeyConditionExpression = Object.keys(keys)
122✔
256
            .map((key) => `#${expressionAttributeKey(key)} = :${expressionAttributeKey(key)}`).join(' AND ');
122✔
257
        const keyExpressionAttributeNames = Object.keys(keys)
122✔
258
            .reduce((acc, key) => ({...acc, [`#${expressionAttributeKey(key)}`]: key}), Object.assign({}));
122✔
259
        const keyExpressionAttributeValues = Object.entries(keys)
122✔
260
            .reduce((acc, [key, value]) => ({...acc, [`:${expressionAttributeKey(key)}`]: value}), Object.assign({}));
122✔
261

262
        const ProjectionExpression = !index && projectedAttributes
122✔
263
            ? projectedAttributes.map((attribute) => `#${expressionAttributeKey(attribute)}`).join(',')
6✔
264
            : undefined;
265
        const projectionAttributeNames: Record<string, string> = !index && projectedAttributes ? projectedAttributes.reduce(
122✔
266
            (
267
                reduction: Record<string, string>,
268
                attribute: string,
269
            ) => ({
6✔
270
                ...reduction,
271
                [`#${expressionAttributeKey(attribute)}`]:
272
                attribute,
273
            }),
274
            Object.assign({}),
275
        ) : {}
276
        const hasFilterExpressions = Array.isArray(filterExpressions) && filterExpressions.length > 0;
122✔
277
        const FilterExpression = hasFilterExpressions
122✔
278
            ? mapFilterExpressions(filterExpressions!)
279
            : undefined;
280
        const filterAttributeNames: Record<string, string> = hasFilterExpressions
122✔
281
            ? filterExpressions!.reduce(
282
                (
283
                    reduction: Record<string, string>,
284
                    filterExpression: FilterExpression,
285
                ) => ({
40✔
286
                    ...reduction,
287
                    [`#${expressionAttributeKey(filterExpression.attribute)}`]:
288
                    filterExpression.attribute,
289
                }),
290
                Object.assign({}),
291
            )
292
            : {};
293
        const filterAttributeValues = filterExpressions
122✔
294
            ? filterExpressions.reduce(
295
                (reduction, filterExpression) => ({
40✔
296
                    ...reduction,
297
                    ...mapFilterExpressionValues(filterExpression),
298
                }),
299
                Object.assign({}),
300
            )
301
            : {};
302

303
        const Limit = limit;
122✔
304
        const ScanIndexForward = sortOrder === "DESC" ? false : undefined;
122✔
305
        const queryCommandInput: QueryCommandInput = {
122✔
306
            TableName: this.tableName,
307
            ReturnConsumedCapacity: this.returnConsumedCapacity,
308
            IndexName: index,
309
            KeyConditionExpression,
310
            FilterExpression,
311
            ProjectionExpression,
312
            ExpressionAttributeNames: {
313
                ...keyExpressionAttributeNames,
314
                ...filterAttributeNames,
315
                ...projectionAttributeNames
316
            },
317
            ExpressionAttributeValues: marshall(
318
                {...keyExpressionAttributeValues, ...filterAttributeValues} as Record<string, NativeAttributeValue>,
319
                {removeUndefinedValues: true},
320
            ),
321
            Limit,
322
            ScanIndexForward
323
        };
324
        const paginator = paginateQuery(
122✔
325
            {client: this.dynamoDBClient, pageSize: 100},
326
            queryCommandInput,
327
        );
328

329
        if (index) {
122✔
330
            const collectedKeys: Array<K> = [];
10✔
331
            for await (const page of paginator) {
32✔
332
                if (page.Items) {
11!
333
                    collectedKeys.push(
11✔
334
                        ...(page.Items.map((item) => unmarshall(item) as T)
143✔
335
                            .map((item: T) =>
336
                                pickBy(item as object, (_, key) => (key === this.hashKey || key === this.rangKey)) as K)),
858✔
337
                    )
338
                }
339
                if (limit && collectedKeys.length >= limit) break;
11!
340
            }
341
            const keysBatch = limit ? collectedKeys.slice(0, limit) : collectedKeys;
10!
342
            const keyAttrs = [this.hashKey, ...(this.rangKey ? [this.rangKey] : [])];
10!
343
            const batchProjectedQuery = projectedAttributes
10✔
344
                ? { ...query, projectedAttributes: [...new Set([...projectedAttributes, ...keyAttrs])] } as ProjectedQuery
345
                : query as ProjectedQuery;
346
            const items = await this.batchGetItems(keysBatch, batchProjectedQuery);
10✔
347
            const orderedItems = keysBatch.flatMap((key) => {
10✔
348
                const k = key as Record<string, unknown>;
143✔
349
                const match = (items as Array<T | undefined>).find((item) => {
143✔
350
                    if (!item) return false;
7,305!
351
                    const t = item as Record<string, unknown>;
7,305✔
352
                    return t[this.hashKey] === k[this.hashKey] &&
7,305✔
353
                        (!this.rangKey || t[this.rangKey] === k[this.rangKey]);
354
                });
355
                return match ? [match] : [];
143!
356
            });
357
            if (projectedAttributes) {
10✔
358
                const projSet = new Set(projectedAttributes);
1✔
359
                return orderedItems.map(item =>
1✔
360
                    pickBy(item as object, (_, key) => projSet.has(key)) as T
12✔
361
                ) as Array<T>;
362
            }
363
            return orderedItems as Array<T>;
9✔
364
        }
365

366
        const items: Array<T> = [];
112✔
367
        for await (const page of paginator) {
336✔
368
            if (page.Items) {
112!
369
                items.push(
112✔
370
                    ...(page.Items?.map((item) => unmarshall(item) as T) || []),
106!
371
                )
372
            }
373
            if (limit && items.length >= limit) break;
112✔
374
        }
375
        return limit ? items.slice(0, limit) : items;
112✔
376
    };
377

378

379
    batchGetItems = async (
5✔
380
        keys: K[], projectedQuery?: ProjectedQuery
381
    ): Promise<Array<T | undefined>> => {
18✔
382
        const uniqueKeys = uniqWith(keys, isEqual);
18✔
383
        const keyPages = paginate(uniqueKeys, 100);
18✔
384
        const {projectedAttributes} = projectedQuery || {};
18✔
385
        const ProjectionExpression = projectedAttributes
18✔
386
            ? projectedAttributes.map((attribute) => `#${expressionAttributeKey(attribute)}`).join(',')
4✔
387
            : undefined;
388
        const ExpressionAttributeNames = projectedAttributes ?
18✔
389
            projectedAttributes.reduce(
390
            (
391
                reduction: Record<string, string>,
392
                attribute: string,
393
            ) => ({
4✔
394
                ...reduction,
395
                [`#${expressionAttributeKey(attribute)}`]:
396
                attribute,
397
            }),
398
            Object.assign({}),
399
        ) : undefined;
400
        return Promise.all((keyPages.map(async (keyPage) => {
18✔
401
            const batchRequest: BatchGetItemCommandInput = {
18✔
402
                RequestItems: {
403
                    [this.tableName]: {
404
                        Keys: keyPage.map((key) => marshallKey(key)),
309✔
405
                        ProjectionExpression,
406
                        ExpressionAttributeNames,
407
                    }
408
                },
409
                ReturnConsumedCapacity: this.returnConsumedCapacity,
410
            }
411
            const items: T[] = [];
18✔
412
            let result = await this.dynamoDBClient.send(new BatchGetItemCommand(batchRequest));
18✔
413
            items.push(...(result.Responses?.[this.tableName]?.map((item) => unmarshall(item) as T) ?? []));
307!
414

415
            let delay = 100;
18✔
416
            while (result.UnprocessedKeys && Object.keys(result.UnprocessedKeys).length > 0) {
18✔
UNCOV
417
                await new Promise(resolve => setTimeout(resolve, delay));
×
418
                delay = Math.min(delay * 2, 3200);
×
419
                result = await this.dynamoDBClient.send(new BatchGetItemCommand({
×
420
                    RequestItems: result.UnprocessedKeys,
421
                    ReturnConsumedCapacity: this.returnConsumedCapacity,
422
                }));
UNCOV
423
                items.push(...(result.Responses?.[this.tableName]?.map((item) => unmarshall(item) as T) ?? []));
×
424
            }
425
            return items;
18✔
426
        })))
427
            .then((itemSets) => itemSets.flat());
18✔
428

429
    };
430
}
431

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