@squiz/db-lib 1.77.0 → 1.77.2

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.
@@ -1465,7 +1465,11 @@ describe('AbstractRepository', () => {
1465
1465
  httpStatusCode: 200,
1466
1466
  },
1467
1467
  });
1468
+
1469
+ // Limit does not appear in the input query.
1470
+ // Splitting/pagination is now handled in the queryFullPartition sub function.
1468
1471
  const input: QueryCommandInput = {
1472
+ ExclusiveStartKey: undefined,
1469
1473
  TableName: TABLE_NAME,
1470
1474
  KeyConditionExpression: '#pkName = :pkValue',
1471
1475
  ExpressionAttributeNames: {
@@ -1474,7 +1478,6 @@ describe('AbstractRepository', () => {
1474
1478
  ExpressionAttributeValues: {
1475
1479
  ':pkValue': 'test_item#foo',
1476
1480
  },
1477
- Limit: 33,
1478
1481
  };
1479
1482
 
1480
1483
  const partialItem = { name: 'foo' };
@@ -1563,6 +1566,181 @@ describe('AbstractRepository', () => {
1563
1566
  new MissingKeyValuesError(`Key field "age" must be specified in the input item in entity test-item-entity`),
1564
1567
  );
1565
1568
  });
1569
+
1570
+ describe('dynamo pagination', () => {
1571
+ const mockItems = [
1572
+ {
1573
+ name: 'foo',
1574
+ age: 10,
1575
+ country: 'au',
1576
+ data: {},
1577
+ },
1578
+ {
1579
+ name: 'fox',
1580
+ age: 11,
1581
+ country: 'au',
1582
+ data: {},
1583
+ },
1584
+ {
1585
+ name: 'bar',
1586
+ age: 12,
1587
+ country: 'au',
1588
+ data: {},
1589
+ },
1590
+ {
1591
+ name: 'baz',
1592
+ age: 13,
1593
+ country: 'au',
1594
+ data: {},
1595
+ },
1596
+ {
1597
+ name: 'qux',
1598
+ age: 14,
1599
+ country: 'au',
1600
+ data: {},
1601
+ },
1602
+ ];
1603
+
1604
+ it('should return all items when there is only 1 page of results', async () => {
1605
+ ddbClientMock.on(QueryCommand).resolves({
1606
+ Items: [mockItems[0], mockItems[1], mockItems[2]],
1607
+ });
1608
+
1609
+ const input: QueryCommandInput = {
1610
+ TableName: TABLE_NAME,
1611
+ KeyConditionExpression: '#pkName = :pkValue',
1612
+ ExpressionAttributeNames: {
1613
+ '#pkName': 'pk',
1614
+ },
1615
+ ExpressionAttributeValues: {
1616
+ ':pkValue': 'test_item#foo',
1617
+ },
1618
+ };
1619
+
1620
+ const partialItem = { name: 'foo' };
1621
+ const result = await repository.queryItems(partialItem);
1622
+ expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 1);
1623
+ expect(ddbClientMock).toHaveReceivedCommandWith(QueryCommand, input);
1624
+ expect(result).toEqual([new TestItem(mockItems[0]), new TestItem(mockItems[1]), new TestItem(mockItems[2])]);
1625
+ });
1626
+
1627
+ it('should return all items when there are more than 1 page of results', async () => {
1628
+ const mockLastEvaluatedKey = { pk: { S: 'test_item#foo' }, sk: { S: 'name#bar' } };
1629
+
1630
+ ddbClientMock
1631
+ .on(QueryCommand)
1632
+ .resolvesOnce({
1633
+ Items: [mockItems[0], mockItems[1], mockItems[2]],
1634
+ LastEvaluatedKey: mockLastEvaluatedKey,
1635
+ })
1636
+ .resolvesOnce({
1637
+ Items: [mockItems[3], mockItems[4]],
1638
+ });
1639
+
1640
+ const input: QueryCommandInput = {
1641
+ TableName: TABLE_NAME,
1642
+ KeyConditionExpression: '#pkName = :pkValue',
1643
+ ExpressionAttributeNames: {
1644
+ '#pkName': 'pk',
1645
+ },
1646
+ ExpressionAttributeValues: {
1647
+ ':pkValue': 'test_item#foo',
1648
+ },
1649
+ };
1650
+
1651
+ const partialItem = { name: 'foo' };
1652
+ const result = await repository.queryItems(partialItem);
1653
+ expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 2);
1654
+ expect(ddbClientMock).toHaveReceivedNthCommandWith(1, QueryCommand, input);
1655
+ expect(ddbClientMock).toHaveReceivedNthCommandWith(2, QueryCommand, {
1656
+ ...input,
1657
+ ExclusiveStartKey: mockLastEvaluatedKey,
1658
+ });
1659
+ expect(result).toEqual([
1660
+ new TestItem(mockItems[0]),
1661
+ new TestItem(mockItems[1]),
1662
+ new TestItem(mockItems[2]),
1663
+ new TestItem(mockItems[3]),
1664
+ new TestItem(mockItems[4]),
1665
+ ]);
1666
+ });
1667
+
1668
+ it('should return items but only up to the provided limit when limit is within first page', async () => {
1669
+ const mockLimit = 2;
1670
+ const mockLastEvaluatedKey = { pk: { S: 'test_item#foo' }, sk: { S: 'name#bar' } };
1671
+
1672
+ ddbClientMock
1673
+ .on(QueryCommand)
1674
+ .resolvesOnce({
1675
+ Items: [mockItems[0], mockItems[1], mockItems[2]],
1676
+ LastEvaluatedKey: mockLastEvaluatedKey,
1677
+ })
1678
+ .resolvesOnce({
1679
+ Items: [mockItems[3], mockItems[4]],
1680
+ });
1681
+
1682
+ const input: QueryCommandInput = {
1683
+ TableName: TABLE_NAME,
1684
+ KeyConditionExpression: '#pkName = :pkValue',
1685
+ ExpressionAttributeNames: {
1686
+ '#pkName': 'pk',
1687
+ },
1688
+ ExpressionAttributeValues: {
1689
+ ':pkValue': 'test_item#foo',
1690
+ },
1691
+ };
1692
+
1693
+ // Only expect first two items from the first page of results.
1694
+ const partialItem = { name: 'foo' };
1695
+ const result = await repository.queryItems(partialItem, { limit: mockLimit });
1696
+ expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 1);
1697
+ expect(ddbClientMock).toHaveReceivedNthCommandWith(1, QueryCommand, input);
1698
+ expect(result).toEqual([new TestItem(mockItems[0]), new TestItem(mockItems[1])]);
1699
+ });
1700
+
1701
+ it('should return items up to the limit when limit spans multiple pages', async () => {
1702
+ const mockLimit = 4;
1703
+ const mockLastEvaluatedKey = { pk: { S: 'test_item#foo' }, sk: { S: 'name#bar' } };
1704
+
1705
+ ddbClientMock
1706
+ .on(QueryCommand)
1707
+ .resolvesOnce({
1708
+ Items: [mockItems[0], mockItems[1], mockItems[2]],
1709
+ LastEvaluatedKey: mockLastEvaluatedKey,
1710
+ })
1711
+ .resolvesOnce({
1712
+ Items: [mockItems[3], mockItems[4]],
1713
+ });
1714
+
1715
+ const input: QueryCommandInput = {
1716
+ TableName: TABLE_NAME,
1717
+ KeyConditionExpression: '#pkName = :pkValue',
1718
+ ExpressionAttributeNames: {
1719
+ '#pkName': 'pk',
1720
+ },
1721
+ ExpressionAttributeValues: {
1722
+ ':pkValue': 'test_item#foo',
1723
+ },
1724
+ };
1725
+
1726
+ // Expect only 4 results in total across both pages.
1727
+ // Expect all 3 results from first page, but only 1 result from second page.
1728
+ const partialItem = { name: 'foo' };
1729
+ const result = await repository.queryItems(partialItem, { limit: mockLimit });
1730
+ expect(ddbClientMock).toHaveReceivedCommandTimes(QueryCommand, 2);
1731
+ expect(ddbClientMock).toHaveReceivedNthCommandWith(1, QueryCommand, input);
1732
+ expect(ddbClientMock).toHaveReceivedNthCommandWith(2, QueryCommand, {
1733
+ ...input,
1734
+ ExclusiveStartKey: mockLastEvaluatedKey,
1735
+ });
1736
+ expect(result).toEqual([
1737
+ new TestItem(mockItems[0]),
1738
+ new TestItem(mockItems[1]),
1739
+ new TestItem(mockItems[2]),
1740
+ new TestItem(mockItems[3]),
1741
+ ]);
1742
+ });
1743
+ });
1566
1744
  });
1567
1745
 
1568
1746
  describe('deleteItem()', () => {
@@ -304,12 +304,8 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
304
304
  if (options?.order !== undefined) {
305
305
  queryCommandInput.ScanIndexForward = options.order === 'asc';
306
306
  }
307
- if (options?.limit !== undefined) {
308
- queryCommandInput.Limit = options.limit;
309
- }
310
307
 
311
- const output = await this.client.query(queryCommandInput);
312
- return !output.Items ? [] : output.Items.map((item) => this.hydrateItem(item));
308
+ return await this.queryFullPartition(queryCommandInput, options?.limit);
313
309
  }
314
310
 
315
311
  /**
@@ -326,6 +322,44 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS
326
322
  throw new Error(`Invalid query filter type: ${(filter as any)?.type}`);
327
323
  }
328
324
 
325
+ /**
326
+ * Get the list of objects by given params, paginating until all results are returned for a full partition.
327
+ * @param params - The DynamoDB query params.
328
+ * @param limit - The maximum number of items to return.
329
+ * @returns Promise of array of items.
330
+ */
331
+ private async queryFullPartition(params: QueryCommandInput, limit?: number): Promise<DATA_CLASS[]> {
332
+ const items: Array<DATA_CLASS> = [];
333
+ let exclusiveStartKey: QueryCommandInput['ExclusiveStartKey'] = undefined;
334
+
335
+ do {
336
+ const queryInput: QueryCommandInput = {
337
+ ...params,
338
+ ExclusiveStartKey: exclusiveStartKey,
339
+ };
340
+
341
+ const result = await this.client.query(queryInput);
342
+
343
+ exclusiveStartKey = result.LastEvaluatedKey;
344
+
345
+ if (result.Items && result.Items.length) {
346
+ const resultItems = result.Items.map((item) => this.hydrateItem(item));
347
+
348
+ const itemsToAdd = limit === undefined ? resultItems : resultItems.slice(0, limit - items.length);
349
+
350
+ items.push(...itemsToAdd);
351
+
352
+ const hasReachedLimit = limit !== undefined && items.length >= limit;
353
+
354
+ if (hasReachedLimit) {
355
+ exclusiveStartKey = undefined;
356
+ }
357
+ }
358
+ } while (exclusiveStartKey);
359
+
360
+ return items;
361
+ }
362
+
329
363
  /**
330
364
  * Update the existing item matching the key fields value
331
365
  * in the passed newValue