From 3b0533b0a93efef50c21379192dc728bb6e6a5b0 Mon Sep 17 00:00:00 2001 From: Pablo Baleztena Date: Sun, 20 Oct 2024 11:36:03 -0300 Subject: [PATCH] [fabric/domain] Add base model validations --- packages/fabric/domain/index.ts | 2 +- .../domain/models/fields/boolean-field.ts | 16 +++ .../domain/models/fields/field-to-type.ts | 6 +- packages/fabric/domain/models/fields/index.ts | 6 +- packages/fabric/domain/models/model.test.ts | 2 +- .../domain/validations/field-parsers.ts | 134 ++++++++++++++++++ packages/fabric/domain/validations/index.ts | 1 + .../domain/validations/parse-from-model.ts | 43 ++++++ 8 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 packages/fabric/domain/models/fields/boolean-field.ts create mode 100644 packages/fabric/domain/validations/field-parsers.ts create mode 100644 packages/fabric/domain/validations/index.ts create mode 100644 packages/fabric/domain/validations/parse-from-model.ts diff --git a/packages/fabric/domain/index.ts b/packages/fabric/domain/index.ts index 73ee54f..c814881 100644 --- a/packages/fabric/domain/index.ts +++ b/packages/fabric/domain/index.ts @@ -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"; diff --git a/packages/fabric/domain/models/fields/boolean-field.ts b/packages/fabric/domain/models/fields/boolean-field.ts new file mode 100644 index 0000000..68c8d9e --- /dev/null +++ b/packages/fabric/domain/models/fields/boolean-field.ts @@ -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( + opts: T = {} as T, +): BooleanField & T { + return { + [VariantTag]: "BooleanField", + ...opts, + } as const; +} diff --git a/packages/fabric/domain/models/fields/field-to-type.ts b/packages/fabric/domain/models/fields/field-to-type.ts index 1230405..5d50dbd 100644 --- a/packages/fabric/domain/models/fields/field-to-type.ts +++ b/packages/fabric/domain/models/fields/field-to-type.ts @@ -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 extends StringField : TField extends DecimalField ? MaybeOptional : TField extends FloatField ? MaybeOptional : TField extends TimestampField ? MaybeOptional + : TField extends BooleanField ? MaybeOptional : TField extends EmbeddedField ? MaybeOptional : never; diff --git a/packages/fabric/domain/models/fields/index.ts b/packages/fabric/domain/models/fields/index.ts index a7f4226..e88d495 100644 --- a/packages/fabric/domain/models/fields/index.ts +++ b/packages/fabric/domain/models/fields/index.ts @@ -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; } diff --git a/packages/fabric/domain/models/model.test.ts b/packages/fabric/domain/models/model.test.ts index c4df9d8..77d4bc5 100644 --- a/packages/fabric/domain/models/model.test.ts +++ b/packages/fabric/domain/models/model.test.ts @@ -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"; diff --git a/packages/fabric/domain/validations/field-parsers.ts b/packages/fabric/domain/validations/field-parsers.ts new file mode 100644 index 0000000..6909fcd --- /dev/null +++ b/packages/fabric/domain/validations/field-parsers.ts @@ -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 + >; +}; +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 = ( + field: TField, + value: unknown, +) => Result | 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 { + 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( + field: FieldDefinition, + value: T | undefined, +): Result { + if (!field.isOptional && value === undefined) { + return Result.failWith(new MissingRequiredFieldError()); + } + return Result.ok(value); +} diff --git a/packages/fabric/domain/validations/index.ts b/packages/fabric/domain/validations/index.ts new file mode 100644 index 0000000..de8552c --- /dev/null +++ b/packages/fabric/domain/validations/index.ts @@ -0,0 +1 @@ +export * from "./parse-from-model.ts"; diff --git a/packages/fabric/domain/validations/parse-from-model.ts b/packages/fabric/domain/validations/parse-from-model.ts new file mode 100644 index 0000000..a2915e0 --- /dev/null +++ b/packages/fabric/domain/validations/parse-from-model.ts @@ -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, SchemaParsingError> { + const parsingErrors = {} as Record; + const parsedValue = {} as ModelToType; + + 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] = 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 + extends TaggedError<"SchemaParsingFailed"> { + constructor( + public readonly errors: Record, + public readonly value?: Partial>, + ) { + super("SchemaParsingFailed"); + } +}