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

eresearchqut / ddb-repository / 19559762280

21 Nov 2025 04:14AM UTC coverage: 91.753% (-0.4%) from 92.147%
19559762280

push

github

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

54 of 66 branches covered (81.82%)

Branch coverage included in aggregate %.

124 of 128 relevant lines covered (96.88%)

153.09 hits per line

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

91.21
/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} 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
}
55

56
const expressionAttributeKey = (key: string) => replace(key, /-/g, "_");
334✔
57

58
const mapInKeys = (filterExpression: FilterExpression) =>
1✔
59
    Array.isArray(filterExpression.value)
6!
60
        ? filterExpression.value.map(
61
            (_, index) => `:${filterExpression.attribute}${index}`,
12✔
62
        )
63
        : `:${filterExpression.attribute}`;
64

65
const mapFilterExpression = (filterExpression: FilterExpression) => {
1✔
66
    switch (filterExpression.operator) {
35✔
67
        case FilterOperator.IN:
68
            return (
6✔
69
                `#${expressionAttributeKey(filterExpression.attribute)} ${filterExpression.operator} ` +
70
                `(${mapInKeys(filterExpression)})`
71
            );
72
        case FilterOperator.BETWEEN:
73
            return (
7✔
74
                `#${expressionAttributeKey(filterExpression.attribute)} ${filterExpression.operator} ` +
75
                `:${expressionAttributeKey(filterExpression.attribute)}0 AND :${expressionAttributeKey(filterExpression.attribute)}1`
76
            );
77
        default:
78
            return (
22✔
79
                `#${expressionAttributeKey(filterExpression.attribute)} ${filterExpression.operator} ` +
80
                `:${expressionAttributeKey(filterExpression.attribute)}`
81
            );
82
    }
83
};
84

85
const mapFilterExpressions = (
1✔
86
    filterExpressions: Array<FilterExpression>,
87
) =>
88
    filterExpressions
32✔
89
        .map((filterExpression) =>
90
            filterExpression.negate
35✔
91
                ? `NOT ${mapFilterExpression(filterExpression)}`
92
                : mapFilterExpression(filterExpression),
93
        )
94
        .join(" AND ");
95

96
const mapFilterExpressionValues = (
1✔
97
    filterExpression: FilterExpression,
98
): Record<string, string | number | boolean> =>
99
    Array.isArray(filterExpression.value)
35✔
100
        ? filterExpression.value.reduce(
101
            (reduction, value, index) => ({
26✔
102
                ...reduction,
103
                [`:${expressionAttributeKey(filterExpression.attribute)}${index}`]: value,
104
            }),
105
            Object.assign({}),
106
        )
107
        : {
108
            [`:${expressionAttributeKey(filterExpression.attribute)}`]:
109
            filterExpression.value,
110
        };
111

112
const paginate = <T>(array: Array<T>, pageSize: number) => {
1✔
113
    return array.reduce((acc, val, i) => {
16✔
114
        const idx = Math.floor(i / pageSize)
303✔
115
        const page = acc[idx] || (acc[idx] = [])
303✔
116
        page.push(val)
303✔
117
        return acc
303✔
118
    }, [] as Array<Array<T>>);
119
}
120

121
export interface DynamoDbRepositoryOptions {
122
    client: DynamoDBClient;
123
    tableName: string;
124
    hashKey: string;
125
    rangeKey?: string;
126
    returnConsumedCapacity?: ReturnConsumedCapacity;
127
}
128

129
export class DynamoDbRepository<K, T> {
1✔
130
    private readonly dynamoDBClient: DynamoDBClient;
131
    private readonly tableName: string;
132
    private readonly hashKey: string;
133
    private readonly rangKey?: string;
134
    private readonly returnConsumedCapacity: ReturnConsumedCapacity | undefined;
135

136
    constructor(options: DynamoDbRepositoryOptions) {
137
        this.dynamoDBClient = options.client;
3✔
138
        this.tableName = options.tableName;
3✔
139
        this.hashKey = options.hashKey;
3✔
140
        this.rangKey = options.rangeKey;
3✔
141
        this.returnConsumedCapacity = options.returnConsumedCapacity ?? ReturnConsumedCapacity.TOTAL;
3!
142
    }
143

144
    getItem = async (key: K): Promise<T | undefined> => {
627✔
145
        return this.dynamoDBClient
627✔
146
            .send(
147
                new GetItemCommand({
148
                    TableName: this.tableName,
149
                    Key: marshall(key, {removeUndefinedValues: true}),
150
                    ReturnConsumedCapacity: this.returnConsumedCapacity,
151
                }),
152
            )
153
            .then((result) =>
154
                result.Item ? unmarshall(result.Item) as T : undefined,
627✔
155
            )
156
    };
157

158
    putItem = async (key: K, record: T): Promise<T> => {
620✔
159
        const Item = marshall({...record, ...key}, {removeUndefinedValues: true});
620✔
160
        return this.dynamoDBClient
620✔
161
            .send(
162
                new PutItemCommand({
163
                    TableName: this.tableName,
164
                    ReturnConsumedCapacity: this.returnConsumedCapacity,
165
                    Item,
166
                }),
167
            )
168
            .then(() => this.getItem(key) as Promise<T>);
620✔
169
    };
170

171
    deleteItem = async (key: K) => {
3✔
172
        return this.dynamoDBClient.send(new DeleteItemCommand({
2✔
173
            TableName: this.tableName,
174
            Key: marshall(key),
175
        })).then((result) => result.Attributes ?
2!
176
            unmarshall(result.Attributes) : undefined);
177
    };
178

179

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

239

240
    getItems = async (
3✔
241
        query: Query
242
    ): Promise<Array<T> | undefined> => {
42✔
243
        const {index, filterExpressions, projectedAttributes, ...keys} = query;
42✔
244
        const KeyConditionExpression = Object.keys(keys)
42✔
245
            .map((key) => `#${expressionAttributeKey(key)} = :${expressionAttributeKey(key)}`).join(' AND ');
42✔
246
        const keyExpressionAttributeNames = Object.keys(keys)
42✔
247
            .reduce((acc, key) => ({...acc, [`#${expressionAttributeKey(key)}`]: key}), Object.assign({}));
42✔
248
        const keyExpressionAttributeValues = Object.entries(keys)
42✔
249
            .reduce((acc, [key, value]) => ({...acc, [`:${expressionAttributeKey(key)}`]: value}), Object.assign({}));
42✔
250

251
        const ProjectionExpression = !index && projectedAttributes
42!
252
            ? projectedAttributes.map((attribute) => `#${expressionAttributeKey(attribute)}`).join(',')
×
253
            : undefined;
254
        const projectionAttributeNames: Record<string, string> = !index && projectedAttributes ? projectedAttributes.reduce(
42!
255
            (
256
                reduction: Record<string, string>,
257
                attribute: string,
258
            ) => ({
×
259
                ...reduction,
260
                [`#${expressionAttributeKey(attribute)}`]:
261
                attribute,
262
            }),
263
            Object.assign({}),
264
        ) : {}
265
        const hasFilterExpressions = Array.isArray(filterExpressions) && filterExpressions.length > 0;
42✔
266
        const FilterExpression = hasFilterExpressions
42✔
267
            ? mapFilterExpressions(filterExpressions)
268
            : undefined;
269
        const filterAttributeNames: Record<string, string> = hasFilterExpressions
42✔
270
            ? filterExpressions.reduce(
271
                (
272
                    reduction: Record<string, string>,
273
                    filterExpression: FilterExpression,
274
                ) => ({
35✔
275
                    ...reduction,
276
                    [`#${expressionAttributeKey(filterExpression.attribute)}`]:
277
                    filterExpression.attribute,
278
                }),
279
                Object.assign({}),
280
            )
281
            : {};
282
        const filterAttributeValues = filterExpressions
42✔
283
            ? filterExpressions.reduce(
284
                (reduction, filterExpression) => ({
35✔
285
                    ...reduction,
286
                    ...mapFilterExpressionValues(filterExpression),
287
                }),
288
                Object.assign({}),
289
            )
290
            : {};
291
        const queryCommandInput: QueryCommandInput = {
42✔
292
            TableName: this.tableName,
293
            ReturnConsumedCapacity: this.returnConsumedCapacity,
294
            IndexName: index,
295
            KeyConditionExpression,
296
            FilterExpression,
297
            ProjectionExpression,
298
            ExpressionAttributeNames: {
299
                ...keyExpressionAttributeNames,
300
                ...filterAttributeNames,
301
                ...projectionAttributeNames
302
            },
303
            ExpressionAttributeValues: marshall(
304
                {...keyExpressionAttributeValues, ...filterAttributeValues},
305
                {removeUndefinedValues: true},
306
            ),
307
        };
308
        const paginator = paginateQuery(
42✔
309
            {client: this.dynamoDBClient, pageSize: 100},
310
            queryCommandInput,
311
        );
312

313
        if (index) {
42✔
314
            const keys: Array<K> = [];
8✔
315
            for await (const page of paginator) {
26✔
316
                if (page.Items) {
9✔
317
                    keys.push(
9✔
318
                        ...(page.Items.map((item) => unmarshall(item) as T)
137✔
319
                            .map((item: T) =>
320
                                pickBy(item as object, (_, key) => (key === this.hashKey || key === this.rangKey)) as K)),
822✔
321
                    )
322
                }
323
            }
324
            const items = await this.batchGetItems(keys, query as ProjectedQuery);
8✔
325
            return items as Array<T>;
8✔
326
        }
327

328
        const items: Array<T> = [];
34✔
329
        for await (const page of paginator) {
102✔
330
            if (page.Items) {
34✔
331
                items.push(
34✔
332
                    ...(page.Items?.map((item) => unmarshall(item) as T) || []),
32!
333
                )
334
            }
335
        }
336
        return items;
34✔
337
    };
338

339

340
    batchGetItems = async (
3✔
341
        keys: K[], projectedQuery?: ProjectedQuery
342
    ): Promise<Array<T | undefined>> => {
16✔
343
        const uniqueKeys = uniqWith(keys, isEqual);
16✔
344
        const keyPages = paginate(uniqueKeys, 100);
16✔
345
        const {projectedAttributes} = projectedQuery || {};
16✔
346
        const ProjectionExpression = projectedAttributes
16✔
347
            ? projectedAttributes.map((attribute) => `#${expressionAttributeKey(attribute)}`).join(',')
2✔
348
            : undefined;
349
        const ExpressionAttributeNames = projectedAttributes ?
16✔
350
            projectedAttributes.reduce(
351
            (
352
                reduction: Record<string, string>,
353
                attribute: string,
354
            ) => ({
2✔
355
                ...reduction,
356
                [`#${expressionAttributeKey(attribute)}`]:
357
                attribute,
358
            }),
359
            Object.assign({}),
360
        ) : undefined;
361
        return Promise.all((keyPages.map(async (keyPage) => {
16✔
362
            const batchRequest: BatchGetItemCommandInput = {
16✔
363
                RequestItems: {
364
                    [this.tableName]: {
365
                        Keys: keyPage.map((key) => (marshall(key))),
303✔
366
                        ProjectionExpression,
367
                        ExpressionAttributeNames,
368
                    }
369
                },
370
                ReturnConsumedCapacity: this.returnConsumedCapacity,
371
            }
372
            return this.dynamoDBClient.send(new BatchGetItemCommand(batchRequest)).then(result =>
16✔
373
                result.Responses?.[this.tableName].map((item) => unmarshall(item) as T));
301!
374
        })))
375
            .then((itemSets) => itemSets.flat());
16✔
376

377
    };
378
}
379

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