[fabric/domain] Improve optional field support and move parsing inside model definition
This commit is contained in:
parent
f30535055f
commit
a053ca225b
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"]>];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user