Skip to content

Commit 3bdab1a

Browse files
anatolzaktywalch
andauthored
fix: resolve hydrate returning no items for composite indexes with keys_only/include projection (#554) (#555)
Co-authored-by: Tyler W. Walch <tywalch@gmail.com>
1 parent 12d7f22 commit 3bdab1a

5 files changed

Lines changed: 228 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,3 +651,6 @@ All notable changes to this project will be documented in this file. Breaking ch
651651
## [3.7.4]
652652
- [Issue #558](https://github.com/tywalch/electrodb/issues/558); Fixed composite index (`type: "composite"`) projection types being too narrow. `keys_only` returned `{}` and array projections omitted pk/sk composite attributes from the response type, even though DynamoDB always returns them as native columns. Response types, `attributes` parameter, and collection queries now correctly include composite key attributes.
653653
- [Issue #559](https://github.com/tywalch/electrodb/issues/559); Fixed composite index query response types marking pk/sk composite attributes as optional even though composite indexes are sparse — items only appear in the index if all key attributes are present, so they are always defined in query results.
654+
655+
## [3.7.5]
656+
- [Issue #554](https://github.com/tywalch/electrodb/issues/554); Fixed `hydrate: true` returning no items when querying composite type indexes with `keys_only` or `include` projections.

src/entity.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ class Entity {
644644
const validKeys = [];
645645
for (let i = 0; i < keys.length; i++) {
646646
const key = keys[i];
647-
const item = this._formatKeysToItem(index, key);
647+
const item = this._formatKeysToItem(TableIndex, key);
648648
if (item !== null) {
649649
items.push(item);
650650
validKeys.push(key);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"KeySchema": [
3+
{ "AttributeName": "pk", "KeyType": "HASH" },
4+
{ "AttributeName": "sk", "KeyType": "RANGE" }
5+
],
6+
"AttributeDefinitions": [
7+
{ "AttributeName": "pk", "AttributeType": "S" },
8+
{ "AttributeName": "sk", "AttributeType": "S" },
9+
{ "AttributeName": "attr1", "AttributeType": "S" },
10+
{ "AttributeName": "attr2", "AttributeType": "S" },
11+
{ "AttributeName": "attr3", "AttributeType": "S" },
12+
{ "AttributeName": "attr4", "AttributeType": "S" },
13+
{ "AttributeName": "attr5", "AttributeType": "S" }
14+
],
15+
"GlobalSecondaryIndexes": [
16+
{
17+
"IndexName": "gsi1",
18+
"KeySchema": [
19+
{ "AttributeName": "attr1", "KeyType": "HASH" },
20+
{ "AttributeName": "attr2", "KeyType": "HASH" },
21+
{ "AttributeName": "attr3", "KeyType": "HASH" },
22+
{ "AttributeName": "attr4", "KeyType": "RANGE" },
23+
{ "AttributeName": "attr5", "KeyType": "RANGE" }
24+
],
25+
"Projection": { "ProjectionType": "ALL" }
26+
},
27+
{
28+
"IndexName": "gsi2",
29+
"KeySchema": [
30+
{ "AttributeName": "attr1", "KeyType": "HASH" },
31+
{ "AttributeName": "attr2", "KeyType": "HASH" },
32+
{ "AttributeName": "attr3", "KeyType": "RANGE" }
33+
],
34+
"Projection": { "ProjectionType": "KEYS_ONLY" }
35+
},
36+
{
37+
"IndexName": "gsi3",
38+
"KeySchema": [
39+
{ "AttributeName": "attr1", "KeyType": "HASH" },
40+
{ "AttributeName": "attr4", "KeyType": "RANGE" },
41+
{ "AttributeName": "attr5", "KeyType": "RANGE" }
42+
],
43+
"Projection": {
44+
"ProjectionType": "INCLUDE",
45+
"NonKeyAttributes": ["attr3"]
46+
}
47+
}
48+
],
49+
"BillingMode": "PAY_PER_REQUEST"
50+
}

test/init.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const issue530 = require("./definitions/issue530.json");
1515
const projectionInclude = require("./definitions/projection-include.json");
1616
const projectionIncludeWithoutEdb = require("./definitions/projection-include-without-edb.json");
1717
const multiattribute = require("./definitions/multiattributekeys.json");
18+
const multiattributeProjections = require("./definitions/multiattribute-projections.json");
1819
const compositeProjection = require("./definitions/composite-projection.json");
1920
const shouldDestroy = process.argv.includes("--recreate");
2021

@@ -93,6 +94,7 @@ async function main() {
9394
createTable(dynamodb, "issue530", issue530),
9495
createTable(dynamodb, "electro_projectioninclude", projectionInclude),
9596
createTable(dynamodb, "electro_projectionincludewithoutedb", projectionIncludeWithoutEdb),
97+
createTable(dynamodb, "multi-attribute-projections", multiattributeProjections),
9698
createTable(dynamodb, "electro_compositeprojection", compositeProjection),
9799
]);
98100
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
process.env.AWS_NODEJS_CONNECTION_REUSE_ENABLED = "1";
2+
3+
import { Entity } from "../index";
4+
import { v4 as uuid } from "uuid";
5+
import { expect } from "chai";
6+
import DynamoDB from "aws-sdk/clients/dynamodb";
7+
8+
const client = new DynamoDB.DocumentClient({
9+
region: "us-east-1",
10+
endpoint: process.env.LOCAL_DYNAMO_ENDPOINT ?? "http://localhost:8000",
11+
credentials: {
12+
accessKeyId: "test",
13+
secretAccessKey: "test",
14+
},
15+
});
16+
17+
function sortBy<T>(arr: T[], key: keyof T): T[] {
18+
return [...arr].sort((a, z) => (a[key] > z[key] ? 1 : -1));
19+
}
20+
21+
describe("composite index hydration", () => {
22+
const table = "multi-attribute-projections";
23+
24+
function createEntity() {
25+
return new Entity(
26+
{
27+
model: {
28+
entity: uuid(),
29+
version: "1",
30+
service: uuid(),
31+
},
32+
attributes: {
33+
id: {
34+
type: "string",
35+
},
36+
organizationId: {
37+
type: "string",
38+
field: "attr1",
39+
},
40+
teamId: {
41+
type: "string",
42+
field: "attr2",
43+
},
44+
city: {
45+
type: "string",
46+
field: "attr3",
47+
},
48+
state: {
49+
type: "string",
50+
field: "attr4",
51+
},
52+
zip: {
53+
type: "string",
54+
field: "attr5",
55+
},
56+
description: {
57+
type: "string",
58+
},
59+
},
60+
indexes: {
61+
primary: {
62+
pk: {
63+
field: "pk",
64+
composite: ["organizationId"],
65+
},
66+
sk: {
67+
field: "sk",
68+
composite: ["id"],
69+
},
70+
},
71+
locationAll: {
72+
index: "gsi1",
73+
type: "composite",
74+
pk: {
75+
composite: ["organizationId", "teamId", "city"],
76+
},
77+
sk: {
78+
composite: ["state", "zip"],
79+
},
80+
},
81+
locationKeysOnly: {
82+
index: "gsi2",
83+
type: "composite",
84+
projection: "keys_only",
85+
pk: {
86+
composite: ["organizationId", "teamId"],
87+
},
88+
sk: {
89+
composite: ["city"],
90+
},
91+
},
92+
locationInclude: {
93+
index: "gsi3",
94+
type: "composite",
95+
projection: ["city"],
96+
pk: {
97+
composite: ["organizationId"],
98+
},
99+
sk: {
100+
composite: ["state", "zip"],
101+
},
102+
},
103+
},
104+
},
105+
{ table, client },
106+
);
107+
}
108+
109+
function makeItems(count: number, overrides: Partial<Record<string, string>>) {
110+
return Array.from({ length: count }, () => ({
111+
id: uuid(),
112+
organizationId: uuid(),
113+
teamId: uuid(),
114+
city: uuid(),
115+
state: uuid(),
116+
zip: uuid(),
117+
description: uuid(),
118+
...overrides,
119+
}));
120+
}
121+
122+
it("should hydrate composite index with ALL projection", async () => {
123+
const entity = createEntity();
124+
const organizationId = uuid();
125+
const teamId = uuid();
126+
const city = uuid();
127+
const items = makeItems(10, { organizationId, teamId, city });
128+
129+
await entity.put(items).go();
130+
const { data } = await entity.query
131+
.locationAll({ organizationId, teamId, city })
132+
.go({ hydrate: true });
133+
134+
expect(sortBy(data, "id")).to.deep.equal(sortBy(items, "id"));
135+
});
136+
137+
it("should hydrate composite index with KEYS_ONLY projection", async () => {
138+
const entity = createEntity();
139+
const organizationId = uuid();
140+
const teamId = uuid();
141+
const items = makeItems(10, { organizationId, teamId });
142+
143+
await entity.put(items).go();
144+
const { data } = await entity.query
145+
.locationKeysOnly({ organizationId, teamId })
146+
.go({ hydrate: true });
147+
148+
expect(sortBy(data, "id")).to.deep.equal(sortBy(items, "id"));
149+
});
150+
151+
it("should hydrate composite index with INCLUDE projection and return non-projected attributes", async () => {
152+
const entity = createEntity();
153+
const organizationId = uuid();
154+
const items = makeItems(10, { organizationId });
155+
156+
await entity.put(items).go();
157+
const { data } = await entity.query
158+
.locationInclude({ organizationId })
159+
.go({ hydrate: true, attributes: ["city", "description"] });
160+
161+
// "description" is not in the GSI projection, so it should only appear via hydration
162+
const expected = sortBy(items, "city").map((item) => ({
163+
city: item.city,
164+
description: item.description,
165+
}));
166+
const actual = sortBy(data, "city").map((item) => ({
167+
city: item.city,
168+
description: item.description,
169+
}));
170+
expect(actual).to.deep.equal(expected);
171+
});
172+
});

0 commit comments

Comments
 (0)