[fabric/domain] Improve optional field support and move parsing inside model definition

This commit is contained in:
Pablo Baleztena 2024-10-23 23:56:56 -03:00
parent f30535055f
commit a053ca225b
2 changed files with 152 additions and 21 deletions

View File

@ -1,8 +1,6 @@
import type { UUID } from "@fabric/core";
import { describe, expectTypeOf, test } from "@fabric/testing";
import type { PosixDate } from "../../core/index.ts";
import { Field } from "./fields/index.ts";
import { Model, type ModelToType } from "./model.ts";
import { describe, expect, test } from "@fabric/testing";
import { Field } from "./fields.ts";
import { Model, type ModelToType, SchemaParsingError } from "./model.ts";
describe("CreateModel", () => {
test("should create a model and it's interface type", () => {
@ -13,14 +11,79 @@ describe("CreateModel", () => {
});
type User = ModelToType<typeof User>;
expectTypeOf<User>().toEqualTypeOf<{
id: UUID;
streamId: UUID;
streamVersion: bigint;
name: string;
password: string;
phone: string | null;
deletedAt: PosixDate | null;
}>();
// expectTypeOf<User>().toEqualTypeOf<{
// id: UUID;
// streamId: UUID;
// streamVersion: bigint;
// name: string;
// password: string;
// phone?: string | undefined;
// deletedAt?: PosixDate | undefined;
// }>();
// deno-lint-ignore no-unused-vars
const p: User = {
id: "123e4567-e89b-12d3-a456-426614174000",
streamId: "123e4567-e89b-12d3-a456-426614174001",
streamVersion: 1n,
name: "John Doe",
password: "password123",
};
});
test("should parse valid data correctly", () => {
const User = Model.aggregateFrom("User", {
name: Field.string(),
password: Field.string(),
phone: Field.string({ isOptional: true }),
});
const result = User.parse({
id: "123e4567-e89b-12d3-a456-426614174000",
streamId: "123e4567-e89b-12d3-a456-426614174001",
streamVersion: 1n,
name: "John Doe",
password: "password123",
phone: "123-456-7890",
});
expect(result.unwrapOrThrow()).toEqual({
id: "123e4567-e89b-12d3-a456-426614174000",
streamId: "123e4567-e89b-12d3-a456-426614174001",
streamVersion: 1n,
name: "John Doe",
password: "password123",
phone: "123-456-7890",
});
});
test("should fail to parse invalid data", () => {
const User = Model.aggregateFrom("User", {
name: Field.string(),
password: Field.string(),
phone: Field.string({ isOptional: true }),
});
const result = User.parse({
id: "invalid-uuid",
streamId: "invalid-uuid",
streamVersion: "not-a-bigint",
name: 123,
password: true,
phone: 456,
deletedAt: "not-a-date",
});
expect(result.isError()).toBe(true);
if (result.isError()) {
const error = result.unwrapErrorOrThrow();
expect(error).toBeInstanceOf(SchemaParsingError);
expect(error.errors).toHaveProperty("id");
expect(error.errors).toHaveProperty("streamId");
expect(error.errors).toHaveProperty("streamVersion");
expect(error.errors).toHaveProperty("name");
expect(error.errors).toHaveProperty("password");
expect(error.errors).toHaveProperty("deletedAt");
}
});
});

View File

@ -1,6 +1,7 @@
import type { Keyof } from "@fabric/core";
import type { FieldToType } from "./fields/field-to-type.ts";
import { Field, type FieldDefinition } from "./fields/index.ts";
// deno-lint-ignore-file no-explicit-any
import { isRecordEmpty, type Keyof, Result, TaggedError } from "@fabric/core";
import { fieldParsers, type FieldParsingError } from "./field-parsers.ts";
import { Field, type FieldDefinition, type FieldToType } from "./fields.ts";
/**
* A model is a schema definition for some type of structured data.
@ -30,6 +31,35 @@ export class Model<
return new Model(name, { ...fields, ...DefaultEntityFields });
}
private constructor(readonly name: TName, readonly fields: TFields) {}
public parse(
value: unknown,
): Result<ModelToType<this>, SchemaParsingError<this>> {
const parsingErrors = {} as Record<keyof this["fields"], FieldParsingError>;
const parsedValue = {} as ModelToType<this>;
for (const key in this.fields) {
const field = this.fields[key]!;
const fieldResult = fieldParsers[field._tag](
field as any,
(value as any)[key],
);
if (fieldResult.isOk()) {
parsedValue[key as keyof ModelToType<this>] = fieldResult.value;
} else {
parsingErrors[key] = fieldResult.unwrapErrorOrThrow();
}
}
if (!isRecordEmpty(parsingErrors)) {
return Result.failWith(
new SchemaParsingError(parsingErrors, parsedValue),
);
} else {
return Result.succeedWith(parsedValue);
}
}
}
export type EntityModel = Model<
@ -42,9 +72,9 @@ export type AggregateModel = Model<
typeof DefaultAggregateFields & ModelFields
>;
export type ModelToType<TModel extends Model> = {
[K in Keyof<TModel["fields"]>]: FieldToType<TModel["fields"][K]>;
};
export type ModelToType<TModel extends Model> =
& ModelToOptionalFields<TModel>
& ModelToRequiredFields<TModel>;
export type ModelFieldNames<TModel extends ModelFields> = Keyof<
TModel["fields"]
@ -56,6 +86,18 @@ export type ModelAddressableFields<TModel extends Model> = {
: never;
}[Keyof<TModel["fields"]>];
export class SchemaParsingError<TModel extends Model>
extends TaggedError<"SchemaParsingFailed"> {
constructor(
public readonly errors: Record<keyof TModel["fields"], FieldParsingError>,
public readonly value?: Partial<ModelToType<TModel>>,
) {
super(
"SchemaParsingFailed",
);
}
}
type ModelFields = Record<string, FieldDefinition>;
const DefaultEntityFields = {
@ -69,5 +111,31 @@ const DefaultAggregateFields = {
isUnsigned: true,
hasArbitraryPrecision: true,
}),
deletedAt: Field.timestamp({ isOptional: true }),
deletedAt: Field.posixDate({ isOptional: true }),
} as const;
type ModelToOptionalFields<TModel extends Model> = {
[K in OptionalFields<TModel>]?: FieldToType<
TModel["fields"][K]
>;
};
type ModelToRequiredFields<TModel extends Model> = {
[K in RequiredFields<TModel>]: FieldToType<
TModel["fields"][K]
>;
};
type OptionalFields<TModel extends Model> = {
[K in Keyof<TModel["fields"]>]: TModel["fields"][K] extends {
isOptional: true;
} ? K
: never;
}[Keyof<TModel["fields"]>];
type RequiredFields<TModel extends Model> = {
[K in Keyof<TModel["fields"]>]: TModel["fields"][K] extends {
isOptional: true;
} ? never
: K;
}[Keyof<TModel["fields"]>];