[fabric/domain] Add base model validations

This commit is contained in:
Pablo Baleztena 2024-10-20 11:36:03 -03:00
parent 87ce76e13f
commit 3b0533b0a9
8 changed files with 205 additions and 5 deletions

View File

@ -4,6 +4,6 @@ export * from "./files/index.ts";
export * from "./models/index.ts";
export * from "./security/index.ts";
export * from "./services/index.ts";
export * from "./types/index.ts";
export * from "./use-case/index.ts";
export * from "./utils/index.ts";
export * from "./validations/index.ts";

View File

@ -0,0 +1,16 @@
import { type TaggedVariant, VariantTag } from "@fabric/core";
import type { BaseField } from "./base-field.ts";
export interface BooleanFieldOptions extends BaseField {}
export interface BooleanField
extends TaggedVariant<"BooleanField">, BooleanFieldOptions {}
export function createBooleanField<T extends BooleanFieldOptions>(
opts: T = {} as T,
): BooleanField & T {
return {
[VariantTag]: "BooleanField",
...opts,
} as const;
}

View File

@ -1,6 +1,7 @@
import type { PosixDate } from "@fabric/core";
import type Decimal from "jsr:@quentinadam/decimal";
import type { UUID } from "../../types/uuid.ts";
import type Decimal from "decimal";
import type { UUID } from "../../../core/types/uuid.ts";
import type { BooleanField } from "./boolean-field.ts";
import type { DecimalField } from "./decimal.ts";
import type { EmbeddedField } from "./embedded.ts";
import type { FloatField } from "./float.ts";
@ -22,6 +23,7 @@ export type FieldToType<TField> = TField extends StringField
: TField extends DecimalField ? MaybeOptional<TField, Decimal>
: TField extends FloatField ? MaybeOptional<TField, number>
: TField extends TimestampField ? MaybeOptional<TField, PosixDate>
: TField extends BooleanField ? MaybeOptional<TField, boolean>
: TField extends EmbeddedField<infer TSubModel>
? MaybeOptional<TField, TSubModel>
: never;

View File

@ -1,3 +1,4 @@
import { type BooleanField, createBooleanField } from "./boolean-field.ts";
import { createDecimalField, type DecimalField } from "./decimal.ts";
import { createEmbeddedField, type EmbeddedField } from "./embedded.ts";
import { createFloatField, type FloatField } from "./float.ts";
@ -12,6 +13,7 @@ import { createUUIDField, type UUIDField } from "./uuid-field.ts";
export * from "./base-field.ts";
export * from "./field-to-type.ts";
export * from "./reference-field.ts";
export * from "./uuid-field.ts";
export type FieldDefinition =
| StringField
@ -21,7 +23,8 @@ export type FieldDefinition =
| DecimalField
| ReferenceField
| TimestampField
| EmbeddedField;
| EmbeddedField
| BooleanField;
export namespace Field {
export const string = createStringField;
@ -32,4 +35,5 @@ export namespace Field {
export const float = createFloatField;
export const timestamp = createTimestampField;
export const embedded = createEmbeddedField;
export const boolean = createBooleanField;
}

View File

@ -1,6 +1,6 @@
import type { UUID } from "@fabric/core";
import { describe, expectTypeOf, test } from "@fabric/testing";
import type { PosixDate } from "../../core/index.ts";
import type { UUID } from "../types/uuid.ts";
import { defineAggregateModel } from "./aggregate-model.ts";
import { Field } from "./fields/index.ts";
import type { ModelToType } from "./model.ts";

View File

@ -0,0 +1,134 @@
import {
PosixDate,
Result,
TaggedError,
type VariantFromTag,
} from "@fabric/core";
import { isUUID, parseAndSanitizeString } from "@fabric/validations";
import type { FieldDefinition, FieldToType } from "../models/index.ts";
export type FieldParsers = {
[K in FieldDefinition["_tag"]]: FieldParser<
VariantFromTag<FieldDefinition, K>
>;
};
export const fieldParsers: FieldParsers = {
StringField: (f, v) => {
return parseStringValue(f, v);
},
UUIDField: (f, v) => {
return parseStringValue(f, v)
.flatMap((parsedString) =>
isUUID(parsedString)
? Result.ok(parsedString)
: Result.failWith(new InvalidFieldTypeError())
);
},
ReferenceField: (f, v) => {
return parseStringValue(f, v)
.flatMap((parsedString) =>
isUUID(parsedString)
? Result.ok(parsedString)
: Result.failWith(new InvalidFieldTypeError())
);
},
TimestampField: (f, v) => {
return parseOptionality(f, v).flatMap((parsedValue) => {
if (parsedValue === undefined) return Result.ok(undefined);
if (!(v instanceof PosixDate)) {
return Result.failWith(new InvalidFieldTypeError());
}
return Result.ok(v);
});
},
BooleanField: (f, v) => {
if (!f.isOptional && v === undefined) {
return Result.failWith(new MissingRequiredFieldError());
}
if (v === undefined) {
return Result.ok(undefined);
}
if (typeof v === "boolean") {
return Result.ok(v);
}
return Result.failWith(new InvalidFieldTypeError());
},
IntegerField: function (): Result<
number | bigint | undefined,
FieldParsingError
> {
throw new Error("Function not implemented.");
},
FloatField: function () {
throw new Error("Function not implemented.");
},
DecimalField: function () {
throw new Error("Function not implemented.");
},
EmbeddedField: function () {
throw new Error("Function not implemented.");
},
};
/**
* A function that takes a field definition and a value and returns a result
*/
export type FieldParser<TField extends FieldDefinition> = (
field: TField,
value: unknown,
) => Result<FieldToType<TField> | undefined, FieldParsingError>;
/**
* Field parsing errors
*/
export type FieldParsingError =
| InvalidFieldTypeError
| MissingRequiredFieldError;
/**
* An error that occurs when a field is invalid
*/
export class InvalidFieldTypeError extends TaggedError<"InvalidField"> {
constructor() {
super("InvalidField");
}
}
/**
* An error that occurs when a required field is missing
*/
export class MissingRequiredFieldError extends TaggedError<"RequiredField"> {
constructor() {
super("RequiredField");
}
}
/**
* Parses a string value including optionality
*/
function parseStringValue(
field: FieldDefinition,
value: unknown,
): Result<string | undefined, FieldParsingError> {
const parsedValue = parseAndSanitizeString(value);
return parseOptionality(field, parsedValue);
}
/**
* Parses the optionality of a field.
* In other words, if a field is required and the value is undefined, it will return a MissingRequiredFieldError.
* If the field is optional and the value is undefined, it will return the value as undefined.
*/
function parseOptionality<T>(
field: FieldDefinition,
value: T | undefined,
): Result<T | undefined, FieldParsingError> {
if (!field.isOptional && value === undefined) {
return Result.failWith(new MissingRequiredFieldError());
}
return Result.ok(value);
}

View File

@ -0,0 +1 @@
export * from "./parse-from-model.ts";

View File

@ -0,0 +1,43 @@
// deno-lint-ignore-file no-explicit-any
import { isRecordEmpty, Result, TaggedError } from "@fabric/core";
import type { FieldDefinition, Model, ModelToType } from "../models/index.ts";
import { fieldParsers, type FieldParsingError } from "./field-parsers.ts";
export function parseFromModel<
TModel extends Model,
>(
model: TModel,
value: unknown,
): Result<ModelToType<TModel>, SchemaParsingError<TModel>> {
const parsingErrors = {} as Record<keyof TModel, FieldParsingError>;
const parsedValue = {} as ModelToType<TModel>;
for (const key in model) {
const field = model[key] as FieldDefinition;
const fieldResult = fieldParsers[field._tag](field as any, value);
if (fieldResult.isOk()) {
parsedValue[key as keyof ModelToType<TModel>] = fieldResult
.value();
} else {
parsingErrors[key] = fieldResult.unwrapErrorOrThrow();
}
}
if (!isRecordEmpty(parsingErrors)) {
return Result.failWith(new SchemaParsingError(parsingErrors, parsedValue));
} else {
return Result.succeedWith(parsedValue);
}
}
export class SchemaParsingError<TModel extends Model>
extends TaggedError<"SchemaParsingFailed"> {
constructor(
public readonly errors: Record<keyof TModel, FieldParsingError>,
public readonly value?: Partial<ModelToType<TModel>>,
) {
super("SchemaParsingFailed");
}
}