diff --git a/.projen/deps.json b/.projen/deps.json index 8e67743..77c223b 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -83,6 +83,10 @@ "name": "@aws-sdk/types", "type": "runtime" }, + { + "name": "@aws-sdk/util-dynamodb", + "type": "runtime" + }, { "name": "@types/aws-lambda", "type": "runtime" diff --git a/.projen/tasks.json b/.projen/tasks.json index 7c56acb..d2a09f7 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -230,7 +230,7 @@ "exec": "yarn install --check-files" }, { - "exec": "yarn upgrade @types/jest @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-import-resolver-node eslint-import-resolver-typescript eslint-plugin-import eslint jest jest-junit json-schema npm-check-updates standard-version ts-jest typescript @aws-sdk/client-dynamodb @aws-sdk/smithy-client @aws-sdk/types @types/aws-lambda aws-sdk" + "exec": "yarn upgrade @types/jest @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-import-resolver-node eslint-import-resolver-typescript eslint-plugin-import eslint jest jest-junit json-schema npm-check-updates standard-version ts-jest typescript @aws-sdk/client-dynamodb @aws-sdk/smithy-client @aws-sdk/types @aws-sdk/util-dynamodb @types/aws-lambda aws-sdk" }, { "exec": "npx projen" diff --git a/.projenrc.js b/.projenrc.js index 9aa5b84..c945397 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -6,6 +6,7 @@ const project = new typescript.TypeScriptProject({ deps: [ "aws-sdk", "@aws-sdk/client-dynamodb", + "@aws-sdk/util-dynamodb", "@aws-sdk/smithy-client", "@aws-sdk/types", "@types/aws-lambda", @@ -13,6 +14,11 @@ const project = new typescript.TypeScriptProject({ eslintOptions: { ignorePatterns: ["**"], }, + tsconfig: { + compilerOptions: { + lib: ["dom"], + }, + }, gitignore: [".DS_Store"], releaseToNpm: true, }); diff --git a/README.md b/README.md index 743dd5d..434cd30 100644 --- a/README.md +++ b/README.md @@ -180,3 +180,89 @@ If you add a `ConditionExpression` in `putItem`, you will be prompted for any `# Same is true for a `query`: ![typesafe query KeyConditionExpression and Filter](img/query-expression.gif) + +### Marshall a JS Object to an AttributeMap + +A better type definition `@aws-sdk/util-dynamodb`'s `marshall` function is provided which maintains the typing information of the value passed to it and also adapts the output type based on the input `marshallOptions` + +Given an object literal: + +```ts +const myObject = { + key: "key", + sort: 123, + binary: new Uint16Array([1]), + buffer: Buffer.from("buffer", "utf8"), + optional: 456, + list: ["hello", "world"], + record: { + key: "nested key", + sort: 789, + }, +} as const; +``` + +Call the `marshall` function to convert it to an AttributeMap which maintains the exact structure in the type system: + +```ts +import { marshall } from "typesafe-dynamodb/lib/marshall"; +// marshall the above JS object to its corresponding AttributeMap +const marshalled = marshall(myObject) + +// typing information is carried across exactly, including literal types +const marshalled: { + readonly key: S<"key">; + readonly sort: N<123>; + readonly binary: B; + readonly buffer: B; + readonly optional: N<456>; + readonly list: L, S<"world">]>; + readonly record: M<...>; +} +``` + +### Unmarshall an AttributeMap back to a JS Object + +A better type definition `@aws-sdk/util-dynamodb`'s `unmarshall` function is provided which maintains the typing information of the value passed to it and also adapts the output type based on the input `unmarshallOptions`. + +```ts +import { unmarshall } from "typesafe-dynamodb/lib/marshall"; + +// unmarshall the AttributeMap back into the original object +const unmarshalled = unmarshall(marshalled); + +// it maintains the literal typing information (as much as possible) +const unmarshalled: { + readonly key: "key"; + readonly sort: 123; + readonly binary: NativeBinaryAttribute; + readonly buffer: NativeBinaryAttribute; + readonly optional: 456; + readonly list: readonly [...]; + readonly record: Unmarshall<...>; +} +``` + +If you specify `{wrapNumbers: true}`, then all `number` types will be wrapped as `{ value: string }`: + +```ts +const unmarshalled = unmarshall(marshalled, { + wrapNumbers: true, +}); + +// numbers are wrapped in { value: string } because of `wrappedNumbers: true` +unmarshalled.sort.value; // string + +// it maintains the literal typing information (as much as possible) +const unmarshalled: { + readonly key: "key"; + // notice how the number is wrapped in the `NumberValue` type? + // this is because `wrapNumbers: true` + readonly sort: NumberValue<123>; + readonly binary: NativeBinaryAttribute; + readonly buffer: NativeBinaryAttribute; + readonly optional: NumberValue<...>; + readonly list: readonly [...]; + readonly record: Unmarshall<...>; +}; +``` diff --git a/package.json b/package.json index 97bd9f1..aaa8dc4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@aws-sdk/client-dynamodb": "^3.50.0", "@aws-sdk/smithy-client": "^3.50.0", "@aws-sdk/types": "^3.50.0", + "@aws-sdk/util-dynamodb": "^3.50.0", "@types/aws-lambda": "^8.10.92", "aws-sdk": "^2.1071.0" }, diff --git a/src/attribute-value.ts b/src/attribute-value.ts index 2b85b0c..283525c 100644 --- a/src/attribute-value.ts +++ b/src/attribute-value.ts @@ -8,6 +8,24 @@ export type AttributeValue = | L> | M>; +export type AttributeMap = Record; + +export type NativeBinaryAttribute = + | ArrayBuffer + | BigInt64Array + | BigUint64Array + | Buffer + | DataView + | Float32Array + | Float64Array + | Int16Array + | Int32Array + | Int8Array + | Uint16Array + | Uint32Array + | Uint8Array + | Uint8ClampedArray; + export type ToAttributeMap = ToAttributeValue["M"]; /** @@ -23,8 +41,12 @@ export type ToAttributeValue = T extends undefined ? S : T extends number ? N - : T extends Date + : // this behavior is not defined by https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html + // should it be a number of string? + T extends Date ? N + : T extends NativeBinaryAttribute + ? B : T extends ArrayLike ? L<{ [i in keyof T]: i extends "length" ? T[i] : ToAttributeValue; diff --git a/src/index.ts b/src/index.ts index 64d05ad..e69de29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +0,0 @@ -export * from "./attribute-value"; diff --git a/src/marshall.ts b/src/marshall.ts new file mode 100644 index 0000000..1bfe322 --- /dev/null +++ b/src/marshall.ts @@ -0,0 +1,66 @@ +import { + marshallOptions, + unmarshallOptions, + marshall as _marshall, + unmarshall as _unmarshall, +} from "@aws-sdk/util-dynamodb"; +import { + AttributeMap, + B, + L, + M, + N, + NativeBinaryAttribute, + S, + ToAttributeMap, +} from "./attribute-value"; + +export const marshall: < + Item extends object, + MarshallOptions extends marshallOptions | undefined +>( + item: Item, + options?: MarshallOptions +) => ToAttributeMap = _marshall; + +export const unmarshall: < + Item extends AttributeMap, + UnmarshallOptions extends unmarshallOptions | undefined +>( + item: Item, + options?: UnmarshallOptions +) => { + [prop in keyof Item]: Unmarshall; +} = _unmarshall as any; + +export interface NumberValue { + value: `${N}`; +} + +export type Unmarshall< + T, + UnmarshallOptions extends unmarshallOptions | undefined +> = T extends S + ? s + : T extends B + ? NativeBinaryAttribute + : T extends N + ? Exclude["wrapNumbers"] extends true + ? NumberValue + : n + : T extends Date + ? string + : T extends L + ? { + [i in keyof Items]: i extends "length" + ? Items[i] + : Unmarshall; + } + : T extends M + ? { + [prop in keyof Attributes]: Unmarshall< + Attributes[prop], + UnmarshallOptions + >; + } + : never; diff --git a/test/marshall.test.ts b/test/marshall.test.ts new file mode 100644 index 0000000..9f73981 --- /dev/null +++ b/test/marshall.test.ts @@ -0,0 +1,87 @@ +import "jest"; + +import { marshall, unmarshall } from "../src/marshall"; + +const myObject = { + key: "key", + sort: 123, + binary: new Uint16Array([1]), + buffer: Buffer.from("buffer", "utf8"), + optional: 456, + list: ["hello", "world"], + record: { + key: "nested key", + sort: 789, + }, +} as const; + +test("should marshall MyItem to ToAttributeMap", () => { + const marshalled = marshall(myObject); + + marshalled.key.S; + marshalled.sort.N; + marshalled.binary?.B; + marshalled.buffer?.B; + marshalled.optional?.N; + marshalled.list?.L[0].S; + marshalled.list?.L[1].S; + // @ts-expect-error + marshalled.list?.L[2]?.S; + marshalled.record.M.key.S; + marshalled.record.M.sort.N; +}); + +test("should unmarshall MyItem from ToAttributeMap", () => { + const marshalled = marshall(myObject); + const unmarshalled = unmarshall(marshalled); + + expect(unmarshalled).toEqual(myObject); + + unmarshalled.key; + unmarshalled.sort; + unmarshalled.binary; + unmarshalled.buffer; + unmarshalled.optional.toString(10); // is a number + unmarshalled.list?.[0]; + unmarshalled.list?.[1]; + // @ts-expect-error + unmarshalled.list?.[2]; + unmarshalled.record.key; + unmarshalled.record.sort.toString(10); // is a number +}); + +test("unmarshall should map numbers to string when wrapNumbers: true", () => { + const marshalled = marshall(myObject); + const unmarshalled = unmarshall(marshalled, { + wrapNumbers: true, + }); + + const expected: typeof unmarshalled = { + ...myObject, + sort: { + value: "123", + }, + optional: { + value: "456", + }, + record: { + key: "nested key", + sort: { + value: "789", + }, + }, + }; + expect(unmarshalled).toEqual(expected); + + unmarshalled.key; + unmarshalled.sort.value; // wrapped NumberValue + unmarshalled.binary; + unmarshalled.buffer; + unmarshalled.optional?.value; // wrapped NumberValue + unmarshalled.list?.[0]; + unmarshalled.list?.[1]; + // @ts-expect-error + unmarshalled.list?.[2]; + unmarshalled.record.key; + unmarshalled.record.sort; +}); diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 2e29dba..af00724 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -7,7 +7,7 @@ "inlineSourceMap": true, "inlineSources": true, "lib": [ - "es2019" + "dom" ], "module": "CommonJS", "noEmitOnError": false, diff --git a/tsconfig.json b/tsconfig.json index 9ffbeed..2bd091f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,7 @@ "inlineSourceMap": true, "inlineSources": true, "lib": [ - "es2019" + "dom" ], "module": "CommonJS", "noEmitOnError": false, diff --git a/yarn.lock b/yarn.lock index ac27cdb..0af4e1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -598,6 +598,13 @@ "@aws-sdk/types" "3.50.0" tslib "^2.3.0" +"@aws-sdk/util-dynamodb@^3.50.0": + version "3.50.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.50.0.tgz#a0388f40a9c391e4b59bae392e4010a41ddda848" + integrity sha512-6ufU74Z8532YFWjXyeQZbe2cnCrWdpf7ezSbrFlQ+iXnX+1RmKZXFhRgHLXugHWXSRAsFau3sMB+6tC0vb9fsw== + dependencies: + tslib "^2.3.0" + "@aws-sdk/util-hex-encoding@3.49.0": version "3.49.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.49.0.tgz#04d153679a1b14b2fecb4c38175d79b4a8fa8002"