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

eresearchqut / ddb-repository / 24969644870

26 Apr 2026 11:21PM UTC coverage: 88.841% (+0.1%) from 88.745%
24969644870

push

github

ryan-bennett
Merge remote-tracking branch 'origin/main'

71 of 93 branches covered (76.34%)

Branch coverage included in aggregate %.

136 of 140 relevant lines covered (97.14%)

153.59 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

179
    deleteItem = async (key: K) => {
3✔
180
        return this.dynamoDBClient.send(new DeleteItemCommand({
3✔
181
            TableName: this.tableName,
182
            Key: marshallKey(key),
183
            ReturnConsumedCapacity: this.returnConsumedCapacity,
184
        })).then((result) => result.Attributes ?
3!
185
            unmarshall(result.Attributes) : undefined);
186
    };
187

188

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

248

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

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

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

327
        if (index) {
47✔
328
            const keys: Array<K> = [];
8✔
329
            for await (const page of paginator) {
26✔
330
                if (page.Items) {
9!
331
                    keys.push(
9✔
332
                        ...(page.Items.map((item) => unmarshall(item) as T)
137✔
333
                            .map((item: T) =>
334
                                pickBy(item as object, (_, key) => (key === this.hashKey || key === this.rangKey)) as K)),
822✔
335
                    )
336
                }
337
            }
338
            const items = await this.batchGetItems(keys, query as ProjectedQuery);
8✔
339
            return items as Array<T>;
8✔
340
        }
341

342
        const items: Array<T> = [];
39✔
343
        for await (const page of paginator) {
121✔
344
            if (page.Items) {
41!
345
                items.push(
41✔
346
                    ...(page.Items?.map((item) => unmarshall(item) as T) || []),
45!
347
                )
348
            }
349
        }
350
        return items;
39✔
351
    };
352

353

354
    batchGetItems = async (
3✔
355
        keys: K[], projectedQuery?: ProjectedQuery
356
    ): Promise<Array<T | undefined>> => {
16✔
357
        const uniqueKeys = uniqWith(keys, isEqual);
16✔
358
        const keyPages = paginate(uniqueKeys, 100);
16✔
359
        const {projectedAttributes} = projectedQuery || {};
16✔
360
        const ProjectionExpression = projectedAttributes
16✔
361
            ? projectedAttributes.map((attribute) => `#${expressionAttributeKey(attribute)}`).join(',')
2✔
362
            : undefined;
363
        const ExpressionAttributeNames = projectedAttributes ?
16✔
364
            projectedAttributes.reduce(
365
            (
366
                reduction: Record<string, string>,
367
                attribute: string,
368
            ) => ({
2✔
369
                ...reduction,
370
                [`#${expressionAttributeKey(attribute)}`]:
371
                attribute,
372
            }),
373
            Object.assign({}),
374
        ) : undefined;
375
        return Promise.all((keyPages.map(async (keyPage) => {
16✔
376
            const batchRequest: BatchGetItemCommandInput = {
16✔
377
                RequestItems: {
378
                    [this.tableName]: {
379
                        Keys: keyPage.map((key) => marshallKey(key)),
303✔
380
                        ProjectionExpression,
381
                        ExpressionAttributeNames,
382
                    }
383
                },
384
                ReturnConsumedCapacity: this.returnConsumedCapacity,
385
            }
386
            const items: T[] = [];
16✔
387
            let result = await this.dynamoDBClient.send(new BatchGetItemCommand(batchRequest));
16✔
388
            items.push(...(result.Responses?.[this.tableName]?.map((item) => unmarshall(item) as T) ?? []));
301!
389

390
            let delay = 100;
16✔
391
            while (result.UnprocessedKeys && Object.keys(result.UnprocessedKeys).length > 0) {
16✔
392
                await new Promise(resolve => setTimeout(resolve, delay));
×
393
                delay = Math.min(delay * 2, 3200);
×
394
                result = await this.dynamoDBClient.send(new BatchGetItemCommand({
×
395
                    RequestItems: result.UnprocessedKeys,
396
                    ReturnConsumedCapacity: this.returnConsumedCapacity,
397
                }));
398
                items.push(...(result.Responses?.[this.tableName]?.map((item) => unmarshall(item) as T) ?? []));
×
399
            }
400
            return items;
16✔
401
        })))
402
            .then((itemSets) => itemSets.flat());
16✔
403

404
    };
405
}
406

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