diff --git a/README.md b/README.md index 14d138c..0d641d9 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Tables: #### A note on `partitionKey` and `sortKey` syntax -Beyonce expects you to specify your partition and sort keys as tuple-2s, e.g. `[Author, $id]`. The first element is a "key prefix" and the 2nd must be a field on your model. For example we set the primary key of the `Author` model above to: `[Author, $id]`, would result in the key: `Author-$id`, where `$id` is the value of a specific Author's id. +Beyonce expects you to specify your partition and sort keys as arrays, e.g. `[Author, $id]`. The first element is a "key prefix" and all subsequent items must be field names on your model. For example we set the primary key of the `Author` model above to: `[Author, $id]`, would result in the key: `Author-$id`, where `$id` is the value of a specific Author's id. And if we wanted a compound key we could do `[Author, $id, $name]`. You can specify compound keys for both partition and sort keys. Using the example above, if we wanted to place `Books` under the same partition key, then we'd need to set the `Book` model's `partitionKey` to `[Author, $authorId]`. diff --git a/package.json b/package.json index c418295..dbd15c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ginger.io/beyonce", - "version": "0.0.37", + "version": "0.0.38", "description": "Type-safe DynamoDB query builder for TypeScript. Designed with single-table architecture in mind.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/main/codegen/generateModels.ts b/src/main/codegen/generateModels.ts index 1213315..bd78b13 100644 --- a/src/main/codegen/generateModels.ts +++ b/src/main/codegen/generateModels.ts @@ -1,16 +1,20 @@ import { Model } from "./types" +import { formatKeyComponent } from "./util" export function generateModels(models: Model[]) { return models.map(generateModel) } function generateModel(model: Model): string { - const [pkPrefix, pk] = model.keys.partitionKey - const [skPrefix, sk] = model.keys.sortKey + const [pkPrefix, ...pkComponents] = model.keys.partitionKey.map( + formatKeyComponent + ) + + const [skPrefix, ...skComponents] = model.keys.sortKey.map(formatKeyComponent) return `export const ${model.name}Model = ${model.tableName}Table .model<${model.name}>(ModelType.${model.name}) - .partitionKey("${pkPrefix}", "${pk.replace("$", "")}") - .sortKey("${skPrefix}", "${sk.replace("$", "")}") + .partitionKey(${pkPrefix}, ${pkComponents.join(", ")}) + .sortKey(${skPrefix}, ${skComponents.join(", ")}) ` } diff --git a/src/main/codegen/generateTables.ts b/src/main/codegen/generateTables.ts index 457b4de..67343c9 100644 --- a/src/main/codegen/generateTables.ts +++ b/src/main/codegen/generateTables.ts @@ -1,4 +1,5 @@ import { Table } from "./types" +import { formatKeyComponent } from "./util" export function generateTables(tables: Table[]): string { const tableCode = tables.map((table) => { @@ -22,16 +23,22 @@ function generateEncryptionBlacklist(table: Table): string { table.partitions .flatMap((_) => _.models) .forEach((model) => { - const [, pk] = model.keys.partitionKey - const [, sk] = model.keys.sortKey - encryptionBlacklistSet.add(pk.replace("$", "")) - encryptionBlacklistSet.add(sk.replace("$", "")) + const [, ...pkComponents] = model.keys.partitionKey + const [, ...skComponents] = model.keys.sortKey + + pkComponents + .map(formatKeyComponent) + .forEach((_) => encryptionBlacklistSet.add(_)) + + skComponents + .map(formatKeyComponent) + .forEach((_) => encryptionBlacklistSet.add(_)) }) - table.gsis.forEach(({ name, partitionKey, sortKey }) => { - encryptionBlacklistSet.add(partitionKey.replace("$", "")) - encryptionBlacklistSet.add(sortKey.replace("$", "")) + table.gsis.forEach(({ partitionKey, sortKey }) => { + encryptionBlacklistSet.add(formatKeyComponent(partitionKey)) + encryptionBlacklistSet.add(formatKeyComponent(sortKey)) }) - return JSON.stringify(Array.from(encryptionBlacklistSet)) + return `[${Array.from(encryptionBlacklistSet).join(", ")}]` } diff --git a/src/main/codegen/types.ts b/src/main/codegen/types.ts index 56309f0..db8c56a 100644 --- a/src/main/codegen/types.ts +++ b/src/main/codegen/types.ts @@ -17,7 +17,7 @@ export type PartitionDefinition = { } export type ModelDefinition = Keys & Fields -export type Keys = { partitionKey: [string, string]; sortKey: [string, string] } +export type Keys = { partitionKey: string[]; sortKey: string[] } export type Fields = { [fieldName: string]: string } export type GSIDefinition = { diff --git a/src/main/codegen/util.ts b/src/main/codegen/util.ts index a2258cf..12f9623 100644 --- a/src/main/codegen/util.ts +++ b/src/main/codegen/util.ts @@ -11,3 +11,7 @@ export function groupBy( return Object.entries(groupedItems) } + +export function formatKeyComponent(component: string): string { + return `"${component.replace("$", "")}"` +} diff --git a/src/main/dynamo/Model.ts b/src/main/dynamo/Model.ts index e64675e..496d975 100644 --- a/src/main/dynamo/Model.ts +++ b/src/main/dynamo/Model.ts @@ -10,9 +10,9 @@ export class Model< constructor( private table: Table, private partitionKeyPrefix: string, - private partitionKeyField: U, + private partitionKeyFields: U[], private sortKeyPrefix: string, - private sortKeyField: V, + private sortKeyFields: V[], readonly modelTag: string ) {} @@ -22,24 +22,27 @@ export class Model< const { partitionKeyPrefix, sortKeyPrefix, - partitionKeyField, - sortKeyField, + partitionKeyFields, + sortKeyFields, } = this + const pkComponents = partitionKeyFields.map((_) => params[_]) + const skComponents = sortKeyFields.map((_) => params[_]) return new PartitionAndSortKey( this.table.partitionKeyName, - this.buildKey(partitionKeyPrefix, params[partitionKeyField]), + this.buildKey(partitionKeyPrefix, ...pkComponents), this.table.sortKeyName, - this.buildKey(sortKeyPrefix, params[sortKeyField]), + this.buildKey(sortKeyPrefix, ...skComponents), this.modelTag ) } partitionKey(params: { [X in U]: string }): PartitionKeyAndSortKeyPrefix { - const { partitionKeyPrefix, partitionKeyField } = this + const { partitionKeyPrefix, partitionKeyFields } = this + const pkComponents = partitionKeyFields.map((_) => params[_]) return new PartitionKeyAndSortKeyPrefix( this.table.partitionKeyName, - this.buildKey(partitionKeyPrefix, params[partitionKeyField]), + this.buildKey(partitionKeyPrefix, ...pkComponents), this.table.sortKeyName, this.sortKeyPrefix, this.modelTag @@ -50,18 +53,18 @@ export class Model< const { partitionKeyPrefix, sortKeyPrefix, - partitionKeyField, - sortKeyField, + partitionKeyFields, + sortKeyFields, modelTag, } = this const fieldsWithTag = { ...fields, model: modelTag } as T - const pk = this.buildKey( - partitionKeyPrefix, - fieldsWithTag[partitionKeyField] - ) - const sk = this.buildKey(sortKeyPrefix, fieldsWithTag[sortKeyField]) + const pkComponents = partitionKeyFields.map((_) => fieldsWithTag[_]) + const skComponents = sortKeyFields.map((_) => fieldsWithTag[_]) + + const pk = this.buildKey(partitionKeyPrefix, ...pkComponents) + const sk = this.buildKey(sortKeyPrefix, ...skComponents) return { ...fieldsWithTag, @@ -70,8 +73,8 @@ export class Model< } } - private buildKey(prefix: string, key: string): string { - return `${prefix}-${key}` + private buildKey(...components: string[]): string { + return components.join("-") } } @@ -79,12 +82,12 @@ export class PartitionKeyBuilder { constructor(private table: Table, private modelTag: string) {} partitionKey( prefix: string, - partitionKeyField: U + ...partitionKeyFields: U[] ): SortKeyBuilder { return new SortKeyBuilder( this.table, prefix, - partitionKeyField, + partitionKeyFields, this.modelTag ) } @@ -94,17 +97,20 @@ export class SortKeyBuilder { constructor( private table: Table, private partitionKeyPrefix: string, - private partitionKeyField: U, + private partitionKeyFields: U[], private modelTag: string ) {} - sortKey(prefix: string, sortKeyField: V): Model { + sortKey( + prefix: string, + ...sortKeyFields: V[] + ): Model { return new Model( this.table, this.partitionKeyPrefix, - this.partitionKeyField, + this.partitionKeyFields, prefix, - sortKeyField, + sortKeyFields, this.modelTag ) } diff --git a/src/test/codegen/generateCode.test.ts b/src/test/codegen/generateCode.test.ts index 8cbfd69..463619a 100644 --- a/src/test/codegen/generateCode.test.ts +++ b/src/test/codegen/generateCode.test.ts @@ -176,7 +176,7 @@ export const MusiciansPartition = MusicTable.partition([MusicianModel]) `) }) -it("should generate table, add partition and sork key to encryption blacklist", () => { +it("should generate table, add partition and sort key to encryption blacklist", () => { const result = generateCode(` Tables: Library: @@ -322,3 +322,49 @@ Tables: expect(lines).toContainEqual(`name: BestNameEvah`) }) + +it("should generate a complex key model", () => { + const result = generateCode(` +Tables: + ComplexLibrary: + Partitions: + ComplexAuthors: + ComplexAuthor: + partitionKey: [Author, $id] + sortKey: [Author, $id, $name] + id: string + name: string +`) + + expect(result).toEqual(`import { Table } from "@ginger.io/beyonce" + +export const ComplexLibraryTable = new Table({ + name: "ComplexLibrary", + partitionKeyName: "pk", + sortKeyName: "sk", + encryptionBlacklist: ["id", "name"] +}) + +export enum ModelType { + ComplexAuthor = "ComplexAuthor" +} + +export interface ComplexAuthor { + model: ModelType.ComplexAuthor + id: string + name: string +} + +export const ComplexAuthorModel = ComplexLibraryTable.model( + ModelType.ComplexAuthor +) + .partitionKey("Author", "id") + .sortKey("Author", "id", "name") + +export type Model = ComplexAuthor + +export const ComplexAuthorsPartition = ComplexLibraryTable.partition([ + ComplexAuthorModel +]) +`) +}) diff --git a/src/test/dynamo/Beyonce.test.ts b/src/test/dynamo/Beyonce.test.ts index 7502acf..8304985 100644 --- a/src/test/dynamo/Beyonce.test.ts +++ b/src/test/dynamo/Beyonce.test.ts @@ -1,5 +1,7 @@ import { FixedDataKeyProvider, JayZ } from "@ginger.io/jay-z" +import { DynamoDB } from "aws-sdk" import crypto from "crypto" +import { Beyonce } from "../../main/dynamo/Beyonce" import { aMusicianWithTwoSongs, byModelAndIdGSI, @@ -9,11 +11,9 @@ import { MusicianPartition, Song, SongModel, + table, } from "./models" import { setup } from "./util" -import { Beyonce } from "../../main/dynamo/Beyonce" -import { table } from "./models" -import { DynamoDB } from "aws-sdk" describe("Beyonce", () => { // Without encryption @@ -21,6 +21,14 @@ describe("Beyonce", () => { await testPutAndRetrieveItem() }) + it("should put and retrieve a model with a compound partition key", async () => { + await testPutAndRetrieveCompoundPartitionKey() + }) + + it("should put and retrieve a model with a compound sort key", async () => { + await testPutAndRetrieveCompoundSortKey() + }) + it("should update a top-level item attribute", async () => { const db = await setup() const [musician, _, __] = aMusicianWithTwoSongs() @@ -213,6 +221,16 @@ describe("Beyonce", () => { await testPutAndRetrieveItem(jayZ) }) + it("should put and retrieve a model with a compound partition key with jayZ", async () => { + const jayZ = await createJayZ() + await testPutAndRetrieveCompoundPartitionKey(jayZ) + }) + + it("should put and retrieve a model with a compound sort key", async () => { + const jayZ = await createJayZ() + await testPutAndRetrieveCompoundSortKey(jayZ) + }) + it("should put and delete an item using pk + sk with jayZ", async () => { await testPutAndDeleteItem() }) @@ -321,6 +339,61 @@ async function testEmptyQuery(jayZ?: JayZ) { expect(result).toEqual({ musician: [], song: [] }) } +async function testPutAndRetrieveCompoundPartitionKey(jayZ?: JayZ) { + interface Person { + model: "person" + first: string + last: string + sortKey: string + } + + const PersonModel = table + .model("person") + .partitionKey("Person", "first", "last") + .sortKey("Person", "sortKey") + + const db = await setup(jayZ) + const model = PersonModel.create({ + first: "Bob", + last: "Smith", + sortKey: "sortKey-123", + }) + await db.put(model) + + const result = await db.get( + PersonModel.key({ first: "Bob", last: "Smith", sortKey: "sortKey-123" }) + ) + + expect(result).toEqual(model) +} + +async function testPutAndRetrieveCompoundSortKey(jayZ?: JayZ) { + interface LineItem { + model: "lineItem" + orderId: string + id: string + timestamp: string + } + + const LineItemModel = table + .model("lineItem") + .partitionKey("OrderId", "orderId") + .sortKey("LineItem", "id", "timestamp") + + const db = await setup(jayZ) + const model = LineItemModel.create({ + id: "l1", + orderId: "o1", + timestamp: "456", + }) + await db.put(model) + + const result = await db.get( + LineItemModel.key({ id: "l1", orderId: "o1", timestamp: "456" }) + ) + expect(result).toEqual(model) +} + async function testQueryWithPaginatedResults(jayZ?: JayZ) { const db = await setup(jayZ)