Skip to content

Commit

Permalink
Merge pull request #53 from dsdavis4/enum_attribute
Browse files Browse the repository at this point in the history
Enum attribute
  • Loading branch information
dsdavis4 authored Oct 15, 2024
2 parents c5eaa30 + 32bf830 commit 2ed30c4
Show file tree
Hide file tree
Showing 14 changed files with 789 additions and 26 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Use the attribute decorators below to define attributes on a model. The decorato
- [@NumberAttribute](https://dyna-record.com/functions/NumberAttribute.html)
- [@BooleanAttribute](https://dyna-record.com/functions/BooleanAttribute.html)
- [@DateAttribute](https://dyna-record.com/functions/DateAttribute.html)
- [@EnumAttribute](https://dyna-record.com/functions/EnumAttribute.html)

- The [alias](https://dyna-record.com/interfaces/AttributeOptions.html#alias) option allows you to specify the attribute name as it appears in the DynamoDB table, different from your class property name.
- Set nullable attributes as optional for optimal type safety
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dyna-record",
"version": "0.1.3",
"version": "0.1.4",
"description": "Typescript Object Relational Mapper (ORM) for Dynamo",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
60 changes: 60 additions & 0 deletions src/decorators/attributes/EnumAttribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { z } from "zod";
import type DynaRecord from "../../DynaRecord";
import Metadata from "../../metadata";
import type { AttributeDecoratorContext, AttributeOptions } from "../types";

/**
* Extends {@link AttributeOptions} but requires that the allowed values are set on `values`
*/
export interface EnumAttributeOptions extends AttributeOptions {
// track this issue for supporting more than strings with zod runtime validations
// https://github.com/colinhacks/zod/issues/2686
values: [string, ...string[]];
}

/**
* A decorator for marking class fields as enum attributes within the context of a single-table design entity. Only the types specified in `values` are allowed via both the type system and runtime schema checks.
*
* This enforces a union type of the specified fields listed in `values`
*
* @template T The entity the decorator is applied to.
* @template K The type of the attribute, restricted to the values listed in `values` prop of EnumAttributeOptions
* @param props An optional object of {@link EnumAttributeOptions}, including the allowed values, configuration options such as metadata attributes and additional property characteristics.
* @returns A class field decorator function that targets and initializes the class's prototype to register the attribute with the ORM's metadata system, ensuring proper handling and validation of the entity's enum values.
*
* Usage example:
* ```typescript
* class Product extends BaseEntity {
* @EnumAttribute({ alias: 'SomeField', values: ["val-1", "val-2"] })
* public someField: "val-1" | "val-2"; // Attribute representing the union/enum types specified in `values`. In this case the only allowed values are "val-1" and "val-2"
*
* @EnumAttribute({ alias: 'MyNullableField', nullable: true, values: ["val-1", "val-2"] })
* public myNullableField?: "val-1" | "val-2"; // Set to Optional for nullable attributes
* }
* ```
*/
function EnumAttribute<
T extends DynaRecord,
const K extends P["values"][number],
const P extends EnumAttributeOptions
>(props: P) {
return function (
_value: undefined,
context: AttributeDecoratorContext<T, K, P>
) {
if (context.kind === "field") {
context.addInitializer(function () {
const entity: DynaRecord = Object.getPrototypeOf(this);

Metadata.addEntityAttribute(entity.constructor.name, {
attributeName: context.name.toString(),
nullable: props?.nullable,
type: z.enum(props.values),
...props
});
});
}
};
}

export default EnumAttribute;
1 change: 1 addition & 0 deletions src/decorators/attributes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export { default as DateAttribute } from "./DateAttribute";
export { default as StringAttribute } from "./StringAttribute";
export { default as BooleanAttribute } from "./BooleanAttribute";
export { default as NumberAttribute } from "./NumberAttribute";
export { default as EnumAttribute } from "./EnumAttribute";
export * from "./serializers";
2 changes: 1 addition & 1 deletion tests/decorators/attributes/BooleanAttribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ describe("BooleanAttribute", () => {
it("when nullable is true, it will allow the property to be optional", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are required
// @ts-expect-no-error: Nullable properties are optional
@BooleanAttribute({ alias: "Key1", nullable: true })
public key1?: boolean;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/decorators/attributes/DateAttribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe("DateAttribute", () => {
it("when nullable is true, it will allow the property to be optional", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are required
// @ts-expect-no-error: Nullable properties are optional
@DateAttribute({ alias: "Key1", nullable: true })
public key1?: Date;
}
Expand Down
198 changes: 198 additions & 0 deletions tests/decorators/attributes/EnumAttribute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Entity, EnumAttribute } from "../../../src/decorators";
import { MockTable } from "../../integration/mockModels";
import Metadata from "../../../src/metadata";
import { ZodEnum, ZodNullable, type ZodString, type ZodOptional } from "zod";
import { type ForeignKey, type NullableForeignKey } from "../../../src";

type EnumValues = "val-1" | "val-2";

@Entity
class MyEntity extends MockTable {
@EnumAttribute({ alias: "SomeEnum", values: ["val-1", "val-2"] })
public readonly someEnum: EnumValues;

@EnumAttribute({ values: ["val-1", "val-2"] })
public readonly noAliasEnum: EnumValues;

@EnumAttribute({
alias: "SomeNullableEnum",
values: ["val-1", "val-2"],
nullable: true
})
public readonly someNullableEnum?: EnumValues;
}

describe("EnumAttribute", () => {
it("uses the provided table alias as attribute metadata if one is provided", () => {
expect.assertions(1);

expect(Metadata.getEntityAttributes(MyEntity.name).someEnum).toEqual({
name: "someEnum",
alias: "SomeEnum",
nullable: false,
type: expect.any(ZodEnum)
});
});

it("defaults attribute metadata alias to the table key if alias is not provided", () => {
expect.assertions(1);

expect(Metadata.getEntityAttributes(MyEntity.name).noAliasEnum).toEqual({
name: "noAliasEnum",
alias: "noAliasEnum",
nullable: false,
type: expect.any(ZodEnum)
});
});

it("zod type is optional if nullable is true", () => {
expect.assertions(1);

expect(
Metadata.getEntityAttributes(MyEntity.name).someNullableEnum
).toEqual({
name: "someNullableEnum",
alias: "SomeNullableEnum",
nullable: true,
type: expect.any(ZodNullable<ZodOptional<ZodString>>)
});
});

describe("types", () => {
it("can be applied to enum attributes", () => {
@Entity
class ModelOne extends MockTable {
// @ts-expect-no-error: EnumValues is a valid type
@EnumAttribute({ alias: "Key1", values: ["1", "2"] })
public key1: "1" | "2";
}

@Entity
class ModelTwo extends MockTable {
// @ts-expect-no-error: EnumValues is a valid type
@EnumAttribute({ alias: "Key1", values: ["val-1", "val-2"] })
public key1: EnumValues;
}
});

it("does not allow the property its applied to to be optional if its not nullable", () => {
@Entity
class ModelOne extends MockTable {
// @ts-expect-error: EnumAttributes cant be optional unless nullable
@EnumAttribute({ alias: "Key1", values: ["val-1", "val-2"] })
public key1?: EnumValues;
}
});

it("does allow the property its applied to to be optional if its nullable", () => {
@Entity
class ModelOne extends MockTable {
// @ts-expect-no-error: EnumAttributes can be optional if nullable
@EnumAttribute({
alias: "Key1",
values: ["val-1", "val-2"],
nullable: true
})
public key1?: EnumValues;
}
});

it("does not support values that are not part of the enum", () => {
@Entity
class ModelOne extends MockTable {
// @ts-expect-error: Only enum values types are allowed
@EnumAttribute({ alias: "Key1", values: ["1", "2"] })
public key1: "1" | "3";
}

@Entity
class ModelTwo extends MockTable {
// @ts-expect-error: Only enum values types are allowed
@EnumAttribute({ alias: "Key1", values: ["val-1", "val-3"] })
public key1: EnumValues;
}

@Entity
class ModelThree extends MockTable {
// @ts-expect-error: Only enum values types are allowed
@EnumAttribute({ alias: "Key1", values: ["1", "2"] })
public key1: string;
}
});

it("'alias' is optional", () => {
@Entity
class ModelOne extends MockTable {
// @ts-expect-no-error: Alias prop is optional
@EnumAttribute({ values: ["1", "2"] })
public key1: "1" | "2";
}
});

it("if nullable is false the attribute is required", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are required
@EnumAttribute({
alias: "Key1",
values: ["val-1", "val-2"],
nullable: false
})
public key1: EnumValues;

// @ts-expect-error: Nullable properties are required
@EnumAttribute({
alias: "Key2",
values: ["val-1", "val-2"],
nullable: false
})
public key2?: EnumValues;
}
});

it("nullable defaults to false and makes the property required", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are required
@EnumAttribute({ values: ["val-1", "val-2"] })
public key1: EnumValues;

// @ts-expect-error: Nullable properties are required
@EnumAttribute({ alias: "Key2", values: ["val-1", "val-2"] })
public key2?: EnumValues;
}
});

it("when nullable is true, it will allow the property to be optional", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are optional
@EnumAttribute({ values: ["val-1", "val-2"], nullable: true })
public key1?: EnumValues;
}
});

it("ForeignKey is not a valid type to apply the EnumAttribute decorator", () => {
@Entity
class ModelOne extends MockTable {
// @ts-expect-error: ForeignKey is not a valid type for Attribute decorator
@EnumAttribute({ values: ["val-1", "val-2"] })
public key1: ForeignKey;
}
});

it("NullableForeignKey is not a valid type to apply the EnumAttribute decorator", () => {
@Entity
class ModelOne extends MockTable {
// @ts-expect-error: NullableForeignKey is not a valid type for EnumAttribute decorator
@EnumAttribute({
alias: "Key1",
nullable: true,
values: ["val-1", "val-2"]
})
public key1?: NullableForeignKey;
}
});
});
});
2 changes: 1 addition & 1 deletion tests/decorators/attributes/ForeignKeyAttribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ describe("ForeignKeyAttribute", () => {
it("when nullable is true, it will allow the property to be optional and be NullableForeignKey", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are required
// @ts-expect-no-error: Nullable properties are optional
@ForeignKeyAttribute({ alias: "Key1", nullable: true })
public key1?: NullableForeignKey;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/decorators/attributes/NumberAttribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ describe("NumberAttribute", () => {
it("when nullable is true, it will allow the property to be optional", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are required
// @ts-expect-no-error: Nullable properties are optional
@NumberAttribute({ alias: "Key1", nullable: true })
public key1?: number;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/decorators/attributes/StringAttribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe("StringAttribute", () => {
it("when nullable is true, it will allow the property to be optional", () => {
@Entity
class SomeModel extends MockTable {
// @ts-expect-no-error: Nullable properties are required
// @ts-expect-no-error: Nullable properties are optional
@StringAttribute({ alias: "Key1", nullable: true })
public key1?: string;
}
Expand Down
Loading

0 comments on commit 2ed30c4

Please sign in to comment.