Skip to content

Commit

Permalink
Merge pull request #52 from dsdavis4/update_instance_method
Browse files Browse the repository at this point in the history
Update instance method
  • Loading branch information
dsdavis4 authored Oct 11, 2024
2 parents 6f41c7f + 5d888d8 commit c5eaa30
Show file tree
Hide file tree
Showing 17 changed files with 5,190 additions and 1,672 deletions.
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[API Documentation](https://dyna-record.com/)

Dyna-Record is a strongly typed ORM (Object-Relational Mapping) tool designed for modeling and interacting with data stored in DynamoDB in a structured and type-safe manner. It simplifies the process of defining data models (entities), performing CRUD operations, and handling complex queries. To support relational data, dyna-record implements a flavor of the [single-table design pattern](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/) and the [adjacency list design pattern](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html). All operations are [ACID compliant transactions\*](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html)\.
Dyna-Record is a strongly typed ORM (Object-Relational Mapping) tool designed for modeling and interacting with data stored in DynamoDB in a structured and type-safe manner. It simplifies the process of defining data models (entities), performing CRUD operations, and handling complex queries. To support relational data, dyna-record implements a flavor of the [single-table design pattern](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/) and the [adjacency list design pattern](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html). All operations are [ACID compliant transactions\*](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html)\. To enforce data integrity beyond the type system, schema validation is performed at runtime.

Note: ACID compliant according to DynamoDB [limitations](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html)

Expand Down Expand Up @@ -310,7 +310,7 @@ class Student extends OtherTable {

The create method is used to insert a new record into a DynamoDB table. This method automatically handles key generation (using UUIDs), timestamps for [createdAt](https://dyna-record.com/classes/default.html#createdAt) and [updatedAt](https://dyna-record.com/classes/default.html#updatedAt) fields, and the management of relationships between entities. It leverages AWS SDK's [TransactWriteCommand](https://www.google.com/search?q=aws+transact+write+command&oq=aws+transact+write+command&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIGCAEQRRg7MgYIAhBFGDvSAQgzMjAzajBqN6gCALACAA&sourceid=chrome&ie=UTF-8) for transactional integrity, ensuring either complete success or rollback in case of any failure. The method handles conditional checks to ensure data integrity and consistency during creation. If a foreignKey is set on create, dyna-record will de-normalize the data required in order to support the relationship

To use the create method, call it on the model class you wish to create a new record for. Pass the properties of the new record as an object argument to the method.
To use the create method, call it on the model class you wish to create a new record for. Pass the properties of the new record as an object argument to the method. Only attributes defined on the model can be configured, and will be enforced via types and runtime schema validation.

#### Basic Usage

Expand Down Expand Up @@ -469,7 +469,7 @@ const result = await Customer.query(

[Docs](https://dyna-record.com/classes/default.html#update)

The update method enables modifications to existing items in a DynamoDB table. It supports updating simple attributes, handling nullable fields, and managing relationships between entities, including updating and removing foreign keys.
The update method enables modifications to existing items in a DynamoDB table. It supports updating simple attributes, handling nullable fields, and managing relationships between entities, including updating and removing foreign keys. Only attributes defined on the model can be updated, and will be enforced via types and runtime schema validation.

#### Updating simple attributes

Expand Down Expand Up @@ -513,6 +513,16 @@ await Pet.update("123", {
});
```

#### Instance Method

There is an instance `update` method that has the same rules above, but returns the full updated instance.

```typescript
const updatedInstance = await petInstance.update({
ownerId: null
});
```

### Delete

[Docs](https://dyna-record.com/classes/default.html#delete)
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.2",
"version": "0.1.3",
"description": "Typescript Object Relational Mapper (ORM) for Dynamo",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
38 changes: 37 additions & 1 deletion src/DynaRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import {
type CreateOptions,
Update,
type UpdateOptions,
Delete
Delete,
type EntityAttributes
} from "./operations";
import type { EntityClass, Optional } from "./types";
import { createInstance } from "./utils";

interface DynaRecordBase {
id: string;
Expand Down Expand Up @@ -258,6 +260,40 @@ abstract class DynaRecord implements DynaRecordBase {
await op.run(id, attributes);
}

/**
* Same as the static `update` method but on an instance. Returns the full updated instance
*
*
* @example Updating an entity.
* ```typescript
* const updatedInstance = await instance.update({ email: "newemail@example.com", profileId: 789 });
* ```
*
* @example Removing a nullable entities attributes
* ```typescript
* const updatedInstance = await instance.update({ email: "newemail@example.com", someKey: null });
* ```
*/
public async update<T extends this>(
attributes: UpdateOptions<T>
): Promise<T> {
const InstanceClass = this.constructor as EntityClass<T>;
const op = new Update<T>(InstanceClass);
const updatedAttributes = await op.run(this.id, attributes);

const clone = structuredClone(this);

// Update the current instance with new attributes
Object.assign(clone, updatedAttributes);

const updatedInstance = Object.fromEntries(
Object.entries(clone).filter(([_, value]) => value !== null)
) as EntityAttributes<T>;

// Return the updated instance, which is of type `this`
return createInstance<T>(InstanceClass, updatedInstance);
}

/**
* Delete an entity by ID
* - Delete all BelongsToLinks
Expand Down
42 changes: 29 additions & 13 deletions src/metadata/EntityMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
} from ".";
import type DynaRecord from "../DynaRecord";
import { ValidationError } from "../errors";
import { type EntityDefinedAttributes } from "../operations";
import Metadata from ".";

type EntityClass = new (...args: any) => DynaRecord;

Expand Down Expand Up @@ -78,45 +80,59 @@ class EntityMetadata {
}

/**
* Validate all an entities attributes (used on create)
* Parse raw entity defined attributes (not reserved/relationship attributes) from input and ensure they are entity defined attributes.
* Any reserved attributes such as primary key, sort key, id, type ,createdAt, updatedAt etc will be omitted.
* If any attributes do not match their schema, a ValidationError is thrown
* @param attributes
*/
public validateFull(attributes: DynaRecord): void {
public parseRawEntityDefinedAttributes(
attributes: EntityDefinedAttributes<DynaRecord>
): EntityDefinedAttributes<DynaRecord> {
if (this.#schema === undefined) {
this.#schema = z.object(this.#zodAttributes);
const tableMeta = Metadata.getTable(this.tableClassName);
this.#schema = z.object(this.#zodAttributes).omit(tableMeta.reservedKeys);
}

try {
this.#schema.parse(attributes);
return this.#schema.parse(attributes);
} catch (error) {
this.handleValidationError(error);
throw this.buildValidationError(error);
}
}

/**
* Validate partial entities attributes (used on update)
* Partial parse raw entity defined attributes (not reserved/relationship attributes) from input and ensure they are entity defined attributes.
* This is similar to `parseRawEntityDefinedAttributes` but will do a partial validation, only validating the entity defined attributes that are present and not rejected if fields are missing.
* Any reserved attributes such as primary key, sort key, id, type ,createdAt, updatedAt etc will be omitted.
* If any attributes do not match their schema, a ValidationError is thrown
* @param attributes
*/
public validatePartial(attributes: Partial<DynaRecord>): void {
public parseRawEntityDefinedAttributesPartial(
attributes: Partial<EntityDefinedAttributes<DynaRecord>>
): Partial<EntityDefinedAttributes<DynaRecord>> {
if (this.#schemaPartial === undefined) {
this.#schemaPartial = z.object(this.#zodAttributes).partial();
const tableMeta = Metadata.getTable(this.tableClassName);
this.#schemaPartial = z
.object(this.#zodAttributes)
.omit(tableMeta.reservedKeys)
.partial();
}

try {
this.#schemaPartial.parse(attributes);
return this.#schemaPartial.parse(attributes);
} catch (error) {
this.handleValidationError(error);
throw this.buildValidationError(error);
}
}

/**
* Throw validation errors with the cause
* Build validation errors with the cause
* @param error
*/
private handleValidationError(error: unknown): void {
private buildValidationError(error: unknown): ValidationError {
const errorOptions =
error instanceof ZodError ? { cause: error.issues } : undefined;
throw new ValidationError("Validation errors", errorOptions);
return new ValidationError("Validation errors", errorOptions);
}
}

Expand Down
29 changes: 29 additions & 0 deletions src/metadata/TableMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ class TableMetadata {
public partitionKeyAttribute: AttributeMetadata;
public sortKeyAttribute: AttributeMetadata;

/**
* Represents the keys that should be excluded from schema validation.
* These keys are reserved by dyna-record and should be managed internally.
*
* While dyna-record employs type guards to prevent the setting of these keys,
* this ensures additional runtime validation.
*
* The reserved keys include:
* - pk
* - sk
* - id
* - type
* - createdAt
* - updatedAt
* - foreignKey
* - foreignEntityType
*/
public reservedKeys: Record<string, true>;

constructor(options: TableMetadataOptions) {
const defaultAttrMeta = this.buildDefaultAttributesMetadata(options);

Expand All @@ -70,6 +89,12 @@ class TableMetadata {
serializers: { toEntityAttribute: () => "", toTableAttribute: () => "" },
type: z.string()
};

const defaultAttrNames = Object.keys(this.defaultAttributes);
// Set the default keys as reserved keys, the user defined primary and sort key are set later
this.reservedKeys = Object.fromEntries(
defaultAttrNames.map(key => [key, true])
);
}

/**
Expand Down Expand Up @@ -116,6 +141,8 @@ class TableMetadata {
public addPartitionKeyAttribute(options: KeysAttributeMetadataOptions): void {
const opts = { ...options, nullable: false };
this.partitionKeyAttribute = new AttributeMetadata(opts);
// Set the user defined primary key as reserved key so that its managed by dyna-record
this.reservedKeys[options.attributeName] = true;
}

/**
Expand All @@ -125,6 +152,8 @@ class TableMetadata {
public addSortKeyAttribute(options: KeysAttributeMetadataOptions): void {
const opts = { ...options, nullable: false };
this.sortKeyAttribute = new AttributeMetadata(opts);
// Set the user defined primary key as reserved key so that its managed by dyna-record
this.reservedKeys[options.attributeName] = true;
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/metadata/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,15 @@ export type JoinTableMetadataStorage = Record<string, JoinTableMetadata[]>;
export type DefaultDateFields = "createdAt" | "updatedAt";

/**
* Specifies the default fields used in entities, including fields from `DynaRecord` or `BelongsToLink`.
* Specifies the default fields used in entities, including fields from `DynaRecord` or `BelongsToLink`. Instance methods are excluded
*/
export type DefaultFields = keyof DynaRecord | keyof BelongsToLink;
export type DefaultFields =
| {
[K in keyof DynaRecord]: DynaRecord[K] extends (...args: any[]) => any
? never
: K;
}[keyof DynaRecord]
| keyof BelongsToLink;

/**
* Defines the structure for default fields within a table, mapping field names to their `AttributeMetadata` aliases.
Expand Down
22 changes: 14 additions & 8 deletions src/operations/Create/Create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ import { entityToTableItem, tableItemToEntity } from "../../utils";
import OperationBase from "../OperationBase";
import { RelationshipTransactions } from "../utils";
import type { CreateOptions } from "./types";
import { type EntityAttributes } from "../types";
import {
type EntityAttributeDefaultFields,
type EntityAttributes
} from "../types";
import Metadata from "../../metadata";

/**
* Represents the operation for creating a new entity in the database, including handling its attributes and any related entities' associations. It will handle de-normalizing data to support relationships
*
* It encapsulates the logic required to translate entity attributes to a format suitable for DynamoDB, execute the creation transaction, and manage any relationships defined by the entity, such as "BelongsTo" or "HasMany" links.
*
* Only attributes defined on the model can be configured, and will be enforced via types and runtime schema validation.
*
* @template T - The type of the entity being created, extending `DynaRecord`.
*/
class Create<T extends DynaRecord> extends OperationBase<T> {
Expand All @@ -30,10 +35,11 @@ class Create<T extends DynaRecord> extends OperationBase<T> {
* @returns
*/
public async run(attributes: CreateOptions<T>): Promise<EntityAttributes<T>> {
const entityData = this.buildEntityData(attributes);

const entityMeta = Metadata.getEntity(this.EntityClass.name);
entityMeta.validateFull(entityData);
const entityAttrs = entityMeta.parseRawEntityDefinedAttributes(attributes);

const reservedAttrs = this.buildReservedAttributes();
const entityData = { ...reservedAttrs, ...entityAttrs };

const tableItem = entityToTableItem(this.EntityClass, entityData);

Expand All @@ -50,7 +56,7 @@ class Create<T extends DynaRecord> extends OperationBase<T> {
* @param attributes
* @returns
*/
private buildEntityData(attributes: CreateOptions<T>): DynaRecord {
private buildReservedAttributes(): EntityAttributes<DynaRecord> {
const id = uuidv4();
const createdAt = new Date();

Expand All @@ -62,14 +68,14 @@ class Create<T extends DynaRecord> extends OperationBase<T> {
[sk]: this.EntityClass.name
};

const defaultAttrs: DynaRecord = {
const defaultAttrs: EntityAttributeDefaultFields = {
id,
type: this.EntityClass.name,
createdAt,
updatedAt: createdAt
};

return { ...keys, ...attributes, ...defaultAttrs };
return { ...keys, ...defaultAttrs };
}

/**
Expand All @@ -92,7 +98,7 @@ class Create<T extends DynaRecord> extends OperationBase<T> {
* @param entityData
*/
private async buildRelationshipTransactions(
entityData: DynaRecord
entityData: EntityAttributes<DynaRecord>
): Promise<void> {
const relationshipTransactions = new RelationshipTransactions({
Entity: this.EntityClass,
Expand Down
8 changes: 6 additions & 2 deletions src/operations/FindById/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { QueryItems } from "../../dynamo-utils";
import type DynaRecord from "../../DynaRecord";
import type { EntityAttributes, RelationshipAttributeNames } from "../types";
import type {
EntityAttributes,
FunctionFields,
RelationshipAttributeNames
} from "../types";
import type { BelongsToLinkDynamoItem } from "../../types";

/**
Expand Down Expand Up @@ -73,5 +77,5 @@ export type FindByIdIncludesRes<
Opts extends FindByIdOptions<T>
> = EntityKeysWithIncludedAssociations<
T,
keyof EntityAttributes<T> | IncludedKeys<T, Opts>
keyof EntityAttributes<T> | IncludedKeys<T, Opts> | FunctionFields<T>
>;
Loading

0 comments on commit c5eaa30

Please sign in to comment.