Skip to content

Commit

Permalink
Merge d0b1123 into 05f1c80
Browse files Browse the repository at this point in the history
  • Loading branch information
tgandrews committed Feb 18, 2023
2 parents 05f1c80 + d0b1123 commit 813b8ae
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 49 deletions.
82 changes: 79 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ A simple and experimental dynamodb data mapper.
- Data validation using [Joi](https://joi.dev/)
- [Autogenerating IDs](#Creating)
- Complete typescript typings
- Basic global indexes
- Range key
- [Basic global indexes](#global-indexes)
- [Range key](#range-keys)
- Lists
- [Versioning](#versioning)

### Missing features

Expand Down Expand Up @@ -310,7 +311,7 @@ import Joi from "joi";

interface User {
id: string;
content: string;
email: string;
}
const UserStore = Omanyd.define<User>({
name: "Users",
Expand Down Expand Up @@ -339,6 +340,81 @@ console.log(user);
*/
```

### Versioning

By default all objects saved with Omanyd get an additional key called `_v`. This holds the version number of the object so that we can automatically migrate it.

As a part of the options you can provide a field called `versions` which holds a list of schema and migration functions.

```ts
import Omanyd from "omanyd";
import Joi from "joi";

interface User {
id: string;
email: string;
}
const UserStore = Omanyd.define<UserV1>({
name: "Users",
hashKey: "id",
schema: Joi.object({
id: Omanyd.types.id(),
email: Joi.string().required(),
}),
});

const user = await UserStore.create({ email: "hello@world.com" });
console.log(user);
/*
* { id: "958f2b51-774a-436a-951e-9834de3fe559", email: "hello@world.com" }
*/
```

Time passes and we need to another version storing more data. We can update it as so:

```ts
interface UserV1 {
id: string;
email: string;
}
interface UserV2 {
id: string;
email: string;
age: number;
}
const UserStore = Omanyd.define<UserV2>({
name: "Users",
hashKey: "id",
schema: Joi.object({
id: Omanyd.types.id(),
email: Joi.string().required(),
age: Joi.string().required(),
}),
versions: [
{
schema: Joi.object({
id: Omanyd.types.id(),
email: Joi.string().required(),
}),
migrate: (userV1: UserV1): UserV2 => {
return {
...userV1,
age: 2,
};
},
},
],
});
// At this point we run the migration defined above. We only run migrations when necessary.
const user = await UserStore.getByHashKey(
"958f2b51-774a-436a-951e-9834de3fe559"
);
console.log(user);
/*
* { id: "958f2b51-774a-436a-951e-9834de3fe559", email: "hello@world.com", age: 2 }
*/
```

## History

Omanyd was originally inspired by [dynamodb](https://www.npmjs.com/package/dynamodb) and [dynamoose](https://www.npmjs.com/package/dynamoose)
Expand Down
16 changes: 12 additions & 4 deletions src/deserializer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as AWSDDB from "@aws-sdk/client-dynamodb";

import { PlainObject } from "./types";
import { PlainObject, VersionedObject } from "./types";

type DynamoType = keyof AWSDDB.AttributeValue;
type AttributeMap = Record<string, AWSDDB.AttributeValue>;
export type AttributeMap = Record<string, AWSDDB.AttributeValue>;

class Deserializer {
fromDynamoValue(attributeValue: AWSDDB.AttributeValue): any {
private fromDynamoValue(attributeValue: AWSDDB.AttributeValue): any {
const [dynamoType, dynamoValue] = Object.entries(attributeValue)[0] as [
DynamoType,
any
Expand Down Expand Up @@ -41,7 +41,7 @@ class Deserializer {
}
}

fromDynamoMap(dbObj: AttributeMap): PlainObject {
private fromDynamoMap(dbObj: AttributeMap): PlainObject {
return Object.entries(dbObj).reduce((userObj, [key, attributeValue]) => {
const userValue = this.fromDynamoValue(attributeValue);
return {
Expand All @@ -50,6 +50,14 @@ class Deserializer {
};
}, {});
}

deserialize(dbObj: AttributeMap): VersionedObject {
const obj = this.fromDynamoMap(dbObj);
return {
_v: 0,
...obj,
};
}
}

export default Deserializer;
154 changes: 150 additions & 4 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AnyRecord } from "dns";
import Joi from "joi";
import Omanyd from "./";

Expand Down Expand Up @@ -362,16 +361,16 @@ describe("omanyd", () => {
await Omanyd.createTables();

const [savedThing1, savedThing2] = await Promise.all([
ThingStore.create({ id: "id", version: "1", value: "hello world" }),
ThingStore.create({ id: "id", version: "2", value: "hello world" }),
ThingStore.create({ id: "id", version: "1", value: "hello world 1" }),
ThingStore.create({ id: "id", version: "2", value: "hello world 2" }),
]);

const readThings = await ThingStore.getAllByHashKey("id");

expect(readThings).toStrictEqual([savedThing1, savedThing2]);
});

it("should return all items for a hash key and if there are multiple items", async () => {
it("should return all items for a hash key - finds none", async () => {
interface Thing {
id: string;
version: string;
Expand Down Expand Up @@ -1142,4 +1141,151 @@ describe("omanyd", () => {
await ThingStore.deleteByHashKey("non-existant-id");
});
});

describe("versioning", () => {
it("should automatically migrate between the old and new version on read", async () => {
interface ThingV0 {
id: string;
value: string;
}
const v0Schema = Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
});
const v0Store = Omanyd.define<ThingV0>({
name: "automaticallyMigrate",
hashKey: "id",
schema: v0Schema,
});
await Omanyd.createTables();
const storedV0Thing = await v0Store.create({ value: "hello" });
expect(storedV0Thing).toStrictEqual({
id: storedV0Thing.id,
value: "hello",
});

interface ThingV1 {
id: string;
value: string;
extra: string;
}
const v1Store = Omanyd.define<ThingV1>({
name: "automaticallyMigrate",
hashKey: "id",
schema: Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
extra: Joi.string().required(),
}),
versions: [
{
schema: v0Schema,
migrate: (thingV0: ThingV0): ThingV1 => {
return {
...thingV0,
extra: "default value",
};
},
},
],
// This wouldn't be needed in prod but needed here
allowNameClash: true,
});

const readV1Item = await v1Store.getByHashKey(storedV0Thing.id);
expect(readV1Item).toStrictEqual({
id: storedV0Thing.id,
value: "hello",
extra: "default value",
});
});

it("should only run the migrations needed", async () => {
interface ThingV0 {
id: string;
value: string;
}
const v0Schema = Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
});

interface ThingV1 {
id: string;
value: string;
extra: string;
}
const v0ToV1Migration = jest.fn((thingV0: ThingV0): ThingV1 => {
return {
...thingV0,
extra: "default value",
};
});
const v1Schema = Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
extra: Joi.string().required(),
});
const v1Store = Omanyd.define<ThingV1>({
name: "automaticallyMigrateOnlyNeeded",
hashKey: "id",
schema: v1Schema,
versions: [
{
schema: v0Schema,
migrate: v0ToV1Migration,
},
],
});
await Omanyd.createTables();
const storedV1Thing = await v1Store.create({
value: "hello",
extra: "extra",
});

interface ThingV2 {
id: string;
value: string;
extra: string;
extra2: number;
}
const v1ToV2Migration = jest.fn((thingV1: ThingV1): ThingV2 => {
return {
...thingV1,
extra2: 5,
};
});
const v2Store = Omanyd.define<ThingV1>({
name: "automaticallyMigrateOnlyNeeded",
hashKey: "id",
schema: Joi.object({
id: Omanyd.types.id(),
value: Joi.string().required(),
extra: Joi.string().required(),
extra2: Joi.number().required(),
}),
versions: [
{
schema: v0Schema,
migrate: v0ToV1Migration,
},
{
schema: v1Schema,
migrate: v1ToV2Migration,
},
],
allowNameClash: true,
});

const readV2Item = await v2Store.getByHashKey(storedV1Thing.id);
expect(readV2Item).toStrictEqual({
id: storedV1Thing.id,
value: "hello",
extra: "extra",
extra2: 5,
});
expect(v0ToV1Migration).not.toHaveBeenCalled();
expect(v1ToV2Migration).toHaveBeenCalled();
});
});
});
26 changes: 7 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ export function define<T extends PlainObject>(options: Options) {
async create(obj: Omit<T, "id"> | T): Promise<T> {
const validated: T = await validator.validateAsync(obj);
const result = await t.create(validated);
const validatedResult = await validator.validateAsync(result);
return validatedResult as unknown as T;
return result as T;
},

async put(obj: T): Promise<T> {
Expand All @@ -48,8 +47,7 @@ export function define<T extends PlainObject>(options: Options) {
if (res === null) {
return res;
}
const validated = await validator.validateAsync(res);
return validated as unknown as T;
return res as T;
},

async getByHashAndRangeKey(
Expand All @@ -64,16 +62,12 @@ export function define<T extends PlainObject>(options: Options) {
if (res === null) {
return res;
}
const validated = await validator.validateAsync(res);
return validated as unknown as T;
return res as T;
},

async getAllByHashKey(hashKey: string): Promise<T[]> {
const res = await t.getAllByHashKey(hashKey);
const validated = await Promise.all(
res.map((data) => validator.validateAsync(data))
);
return validated as unknown[] as T[];
const validated = await t.getAllByHashKey(hashKey);
return validated as T[];
},

async getByIndex(
Expand All @@ -85,18 +79,12 @@ export function define<T extends PlainObject>(options: Options) {
if (res === null) {
return res;
}
const validated = await validator.validateAsync(res);
return validated as unknown as T;
return res as T;
},

async scan(): Promise<T[]> {
const res = await t.scan();
const validated = await Promise.all(
res.map(async (r) => {
return await validator.validateAsync(r);
})
);
return validated as unknown as T[];
return res as T[];
},

async deleteByHashKey(hashKey: string): Promise<void> {
Expand Down
Loading

0 comments on commit 813b8ae

Please sign in to comment.