From 975c038cadfb234b7c3713697da5677de7daed5a Mon Sep 17 00:00:00 2001 From: mararok <5163714+Mararok@users.noreply.github.com> Date: Thu, 22 Aug 2024 21:26:54 +0200 Subject: [PATCH] feat: hobject initial --- .eslintrc.js | 2 +- .npmignore | 3 +- benchmark/.gitignore | 21 ++ benchmark/README.md | 18 + benchmark/package.json | 15 + benchmark/src/HObjectParseBenchmark.mts | 295 +++++++++++++++ benchmark/src/main.mts | 1 + benchmark/yarn.lock | 29 ++ src/Domain/ValueObject/ValueObject.ts | 19 +- src/Util/Dto.ts | 114 +----- src/Util/Feature/HObjectTypeMeta.ts | 92 +++++ src/Util/Feature/index.ts | 2 + src/Util/Feature/types.ts | 1 + src/Util/Plain/PlainParseHelper.ts | 171 +++++++++ src/Util/Plain/PlainParseIssue.ts | 341 ++++++++++++++++++ src/Util/Plain/index.ts | 2 + src/Util/Plain/types.ts | 59 +++ src/Util/functions.ts | 7 +- src/Util/index.ts | 4 + src/Util/types.ts | 58 +++ test/helper/TestDto.ts | 154 ++++++++ test/unit/Util/Dto.test.ts | 57 +-- .../Util/Plain/ObjectPropertyTypes.test.ts | 61 ++++ test/unit/Util/Plain/PlainParseHelper.test.ts | 158 ++++++++ 24 files changed, 1556 insertions(+), 128 deletions(-) create mode 100644 benchmark/.gitignore create mode 100644 benchmark/README.md create mode 100644 benchmark/package.json create mode 100644 benchmark/src/HObjectParseBenchmark.mts create mode 100644 benchmark/src/main.mts create mode 100644 benchmark/yarn.lock create mode 100644 src/Util/Feature/HObjectTypeMeta.ts create mode 100644 src/Util/Feature/index.ts create mode 100644 src/Util/Feature/types.ts create mode 100644 src/Util/Plain/PlainParseHelper.ts create mode 100644 src/Util/Plain/PlainParseIssue.ts create mode 100644 src/Util/Plain/index.ts create mode 100644 src/Util/Plain/types.ts create mode 100644 test/helper/TestDto.ts create mode 100644 test/unit/Util/Plain/ObjectPropertyTypes.test.ts create mode 100644 test/unit/Util/Plain/PlainParseHelper.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index 06115bb..0c4b44e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,6 @@ module.exports = { 'array-bracket-newline': 'off', 'function-call-argument-newline': 'off', 'no-console': 'off', - "unused-imports/no-unused-imports": "warn", + "unused-imports/no-unused-imports": "warn" }, }; diff --git a/.npmignore b/.npmignore index 8dc4d01..66c07e4 100644 --- a/.npmignore +++ b/.npmignore @@ -19,4 +19,5 @@ eslint.js .eslintignore CHANGELOG.md .jestrc.json -bin/ \ No newline at end of file +bin/ +benchmark/ \ No newline at end of file diff --git a/benchmark/.gitignore b/benchmark/.gitignore new file mode 100644 index 0000000..78e9c9f --- /dev/null +++ b/benchmark/.gitignore @@ -0,0 +1,21 @@ +# YARN +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +node_modules +build/ + +# YALC +.yalc +yalc.lock + +# build/test artifacts +lib/ +*.tsbuildinfo +/**/test/coverage +coverage +*.log diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..5888a01 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,18 @@ +# benchmark + +Local publish package +```bash +yalc publish +``` + +Linking local version +```bash +cd benchmark +yarn run yalc-common +``` + +Running benchmarks +```bash +cd benchmark +yarn run bench +``` \ No newline at end of file diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 0000000..47dda40 --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,15 @@ +{ + "name": "@hexancore/common-benchmark", + "packageManager": "yarn@4.0.2", + "private": true, + "version": "0.1.0", + "description": "Benchmark of Hexancore Common Package", + "scripts": { + "yalc-common": "yalc link @hexnacore/common", + "bench": "tsx src/main.mts" + }, + "dependencies": { + "tinybench": "^2.9.0", + "zod": "^3.23.8" + } +} diff --git a/benchmark/src/HObjectParseBenchmark.mts b/benchmark/src/HObjectParseBenchmark.mts new file mode 100644 index 0000000..1bc43cd --- /dev/null +++ b/benchmark/src/HObjectParseBenchmark.mts @@ -0,0 +1,295 @@ +import { Bench } from 'tinybench'; + +import { + ValueObject, + UIntValue, Dto, + type JsonObjectType, + type PlainParsableHObjectType, + type R, type PlainParseError, + PlainParseHelper, + InvalidTypePlainParseIssue, + PlainParseIssue, + OK, + HObjectTypeMeta, + TooBigPlainParseIssue, +} from "@hexancore/common"; + +import { union, z } from 'zod'; + +@ValueObject('Test') +export class TestValueObject extends UIntValue { + public static HOBJ_META = HObjectTypeMeta.application('core', 'core', 'value_object', 'Test', TestValueObject); +} + +class OtherTestDto extends Dto { + + public static HOBJ_META = HObjectTypeMeta.application('core', 'core', 'dto', 'OtherTest', OtherTestDto); + public constructor( + public primitiveField?: number + ) { + super(); + } + + // AOT generated example + public static parse(this: PlainParsableHObjectType, plain: unknown): R { + // constant check part + if (typeof plain !== 'object') { + return PlainParseHelper.HObjectParseErr(this, [new InvalidTypePlainParseIssue('object', typeof plain)]); + } + + const plainObj = plain as Record; + const issues: PlainParseIssue[] = []; + // end constant check part + + const primitiveField = PlainParseHelper.parseNumber(plainObj.primitiveField, 'primitiveField', issues); + if (!(primitiveField instanceof PlainParseIssue) && primitiveField > 2000) { + issues.push(TooBigPlainParseIssue.numberLT(2000, primitiveField)); + } + + if (issues.length > 0) { + return PlainParseHelper.HObjectParseErr(this, issues); + } + + return OK(new this( + primitiveField + )); + } + + public toJSON(): JsonObjectType { + return { + primitiveField: this.primitiveField, + }; + } +} + +export class TestDto extends Dto { + + public static HOBJ_META = HObjectTypeMeta.application('core', 'core', 'dto', 'Test', TestDto); + + public constructor( + public bigIntField: bigint, + public numberField: number, + public numberArrayField: number[], + public booleanField: boolean, + public unionField: number | boolean | string, + + public optionalValueObjectField?: TestValueObject, + public optionalValueObjectArrayField?: TestValueObject[], + public optionalDtoField?: OtherTestDto, + public optionalDtoArrayField?: OtherTestDto[], + ) { + super(); + } + + // AOT generated example + public static parse(this: PlainParsableHObjectType, plain: unknown): R { + // constant check part + if (typeof plain !== 'object') { + return PlainParseHelper.HObjectParseErr(this, [new InvalidTypePlainParseIssue('object', typeof plain)]); + } + + const p = plain as Record; + const issues: PlainParseIssue[] = []; + // end constant check part + + + const bigIntField = PlainParseHelper.parseBigInt64(p.bigIntField, 'bigIntField', issues); + const numberField = PlainParseHelper.parseNumber(p.numberField, 'numberField', issues); + if (!(numberField instanceof PlainParseIssue) && numberField > 2000) { + issues.push(TooBigPlainParseIssue.numberLT(2000, numberField)); + } + + const numberArrayField = PlainParseHelper.parsePrimitiveArray(p.numberArrayField, PlainParseHelper.parseNumber, 'numberArrayField', issues); + const booleanField = PlainParseHelper.parseBoolean(p.booleanField, 'booleanField', issues); + + let valueObjectField; + if (p.optionalValueObjectField !== undefined) { + valueObjectField = PlainParseHelper.parseHObject(p.optionalValueObjectField, TestValueObject, 'optionalValueObjectField', issues); + } + + let optionalValueObjectArrayField; + if (p.optionalValueObjectArrayField) { + optionalValueObjectArrayField = PlainParseHelper.parseHObjectArray(p.optionalValueObjectArrayField, TestValueObject, 'optionalValueObjectArrayField', issues); + } + + let optionalDtoField; + if (p.optionalDtoField !== undefined) { + optionalDtoField = PlainParseHelper.parseHObject(p.optionalDtoField, OtherTestDto, 'optionalDtoField', issues); + } + + let optionalDtoArrayField; + if (p.optionalDtoArrayField !== undefined) { + optionalDtoArrayField = PlainParseHelper.parseHObjectArray(p.optionalDtoArrayField, OtherTestDto, 'optionalDtoArrayField', issues); + } + + let unionField; + switch (typeof p.unionField) { + case 'number': + unionField = p.unionField; + break; + case 'boolean': + unionField = p.unionField; + break; + case 'string': + unionField = p.unionField; + break; + } + if (unionField === undefined) { + issues.push(new InvalidTypePlainParseIssue(['number', 'boolean', 'string'], typeof p.unionField, 'unionField')); + } + + if (issues.length > 0) { + return PlainParseHelper.HObjectParseErr(this, issues); + } + + return OK(new this( + bigIntField, + numberField, + numberArrayField, + booleanField, + unionField, + valueObjectField, + optionalValueObjectArrayField, + optionalDtoField, + optionalDtoArrayField + )); + } + + + // AOT generated example + public toJSON(): JsonObjectType { + return { + bigIntField: this.bigIntField.toString(), + numberField: this.numberField, + numberArrayField: this.numberArrayField, + booleanField: this.booleanField, + unionField: this.unionField, + optionalValueObjectField: this.optionalValueObjectField?.toJSON(), + optionalValueObjectArrayField: this.optionalValueObjectArrayField?.map(v => v.toJSON()), + optionalDtoField: this.optionalDtoField?.toJSON(), + optionalDtoArrayField: this.optionalDtoArrayField?.map((v) => v.toJSON()), + }; + } +} + +const plain: JsonObjectType = { + bigIntField: '1000', + + numberField: 1000, + numberArrayField: [1000], + + booleanField: true, + unionField: 'super', + + optionalValueObjectField: 1000, + optionalValueObjectArrayField: [1000, 2000, 3000], + + optionalDtoField: { + primitiveField: 1000 + }, + optionalDtoArrayField: [{ primitiveField: 1000 }, { primitiveField: 2000 }] +}; + +const invalidPlain: JsonObjectType | any = { + bigIntField: '1000', + + numberField: 'fff', + numberArrayField: [1000], + + booleanField: true, + + optionalValueObjectField: 1000, + optionalValueObjectArrayField: [1000, 2000, 3000], + + optionalDtoField: { + primitiveField: 'not_number' + }, + optionalDtoArrayField: [{ primitiveField: 1000 }, { primitiveField: 'not_number' }] +}; + + +const zodOtherTestDtoSchema = z.object({ + primitiveField: z.number().max(2000).optional(), +}).transform((v) => new OtherTestDto(v.primitiveField)); + +type a = z.infer; + +const zodTestDtoSchema = z.object({ + bigIntField: z.string().regex(/^-?\d{1,19}$/).transform((v) => BigInt(v)), + numberField: z.number().max(2000), + numberArrayField: z.array(z.number()), + booleanField: z.boolean(), + unionField: z.union([z.number(), z.boolean(), z.string()]), + optionalDtoField: zodOtherTestDtoSchema.optional(), + optionalValueObjectField: z.number().optional().transform((v) => v ? new TestValueObject(v) : undefined), + optionalValueObjectArrayField: z.array(z.number().transform((v) => v ? new TestValueObject(v) : undefined)).optional(), + optionalDtoArrayField: z.array(zodOtherTestDtoSchema).optional(), +}); + +const zodNoTransformOtherTestDtoSchema = z.object({ + primitiveField: z.number().max(2000), +}); + +const zodNoTransformTestDtoSchema = z.object({ + bigIntField: z.string().regex(/^-?\d{1,19}$/).transform((v) => BigInt(v)), + numberField: z.number().max(2000), + numberArrayField: z.array(z.number()), + booleanField: z.boolean(), + unionField: z.union([z.number(), z.boolean(), z.string()]), + optionalDtoField: zodNoTransformOtherTestDtoSchema.optional(), + optionalValueObjectField: z.number().optional(), + optionalValueObjectArrayField: z.array(z.number()).optional(), + optionalDtoArrayField: z.array(zodOtherTestDtoSchema).optional(), +}); + +const bench = new Bench({ time: 3000, iterations: 8, warmupTime: 300 }); + +bench + .add('HObject.parse - valid plain', () => { + const obj = TestDto.parse(plain); + if (obj.isError()) { + throw new Error("imposible"); + } + }) + .add('Zod - with-transforms - valid plain', () => { + try { + const obj = TestDto.cs(zodTestDtoSchema.parse(plain) as any); + } catch (e) { + throw e; + } + }) + .add('HObject.parse - invalid plain', () => { + const obj = TestDto.parse(invalidPlain); + if (obj.isError()) { + + } + }) + .add('Zod - with-transforms - invalid plain', () => { + try { + const obj = TestDto.cs(zodTestDtoSchema.parse(invalidPlain) as any); + } catch (e) { + + } + }) + .add('Zod - no-transforms - valid plain', () => { + try { + const obj = zodNoTransformTestDtoSchema.parse(plain); + } catch (e) { + + } + }) + .add('Zod - no-transforms - invalid plain', () => { + try { + const obj = zodNoTransformTestDtoSchema.parse(invalidPlain); + } catch (e) { + + } + }); + +//bench.threshold = 10 +//bench.concurrency = "task" + +await bench.warmup(); +await bench.run(); + +console.table(bench.table()); \ No newline at end of file diff --git a/benchmark/src/main.mts b/benchmark/src/main.mts new file mode 100644 index 0000000..2776b91 --- /dev/null +++ b/benchmark/src/main.mts @@ -0,0 +1 @@ +import './HObjectParseBenchmark.mjs'; \ No newline at end of file diff --git a/benchmark/yarn.lock b/benchmark/yarn.lock new file mode 100644 index 0000000..16f3af7 --- /dev/null +++ b/benchmark/yarn.lock @@ -0,0 +1,29 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10 + +"@hexancore/common-benchmark@workspace:.": + version: 0.0.0-use.local + resolution: "@hexancore/common-benchmark@workspace:." + dependencies: + tinybench: "npm:^2.9.0" + zod: "npm:^3.23.8" + languageName: unknown + linkType: soft + +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: cfa1e1418e91289219501703c4693c70708c91ffb7f040fd318d24aef419fb5a43e0c0160df9471499191968b2451d8da7f8087b08c3133c251c40d24aced06c + languageName: node + linkType: hard + +"zod@npm:^3.23.8": + version: 3.23.8 + resolution: "zod@npm:3.23.8" + checksum: 846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1 + languageName: node + linkType: hard diff --git a/src/Domain/ValueObject/ValueObject.ts b/src/Domain/ValueObject/ValueObject.ts index 2f4443a..8442e33 100644 --- a/src/Domain/ValueObject/ValueObject.ts +++ b/src/Domain/ValueObject/ValueObject.ts @@ -1,4 +1,4 @@ -import { AppError, ERR, JsonSerialize, pascalCaseToSnakeCase, Result } from '../../Util'; +import { AppError, CustomPlainParseIssue, ERR, JsonSerialize, pascalCaseToSnakeCase, PlainParseHelper, Result,type PlainParsableHObjectType, type PlainParseError, type R } from '../../Util'; export interface ValueObjectMeta { readonly module: string; @@ -8,8 +8,8 @@ export interface ValueObjectMeta { export const VALUE_OBJECT_META_PROPERTY = '__VOMETA'; export type AnyValueObject = AbstractValueObject; +export type ValueObjectConstructor = PlainParsableHObjectType; -export type ValueObjectConstructor = new (...args: any[]) => T; /** * Decorator @@ -58,6 +58,21 @@ export abstract class AbstractValueObject implements J return ERR(ValueObjectInvalidRawValueError(meta, data)); } + /** + * Creates ValueObject from plain value + * @param this + * @param plain + * @returns + */ + public static parse(this: ValueObjectConstructor, plain: unknown): R { + const result: R = this['c'](plain); + if (result.isError()) { + return PlainParseHelper.HObjectParseErr(this, [new CustomPlainParseIssue(result.e.data, 'invalid plain')]); + } + + return result as any; + } + public abstract equals(o: T): boolean; public abstract toString(): string; public abstract toJSON(): any; diff --git a/src/Util/Dto.ts b/src/Util/Dto.ts index ad5de67..7d155ba 100644 --- a/src/Util/Dto.ts +++ b/src/Util/Dto.ts @@ -1,126 +1,38 @@ -import { AbstractValueObject } from '@/Domain'; -import { instanceToPlain, plainToInstance, Transform, TransformationType } from 'class-transformer'; -import { INTERNAL_ERROR} from './Error/AppError'; -import { ERR, OK, Result } from './Result'; +import { LogicError } from './Error'; +import { HObjectType, type PlainParsableHObjectType } from './Feature/HObjectTypeMeta'; import { JsonSerialize } from './Json/JsonSerialize'; +import type { PlainParseError } from './Plain'; +import { type R } from './Result'; +import type { NonMethodProperties } from './types'; -export const INVALID_PLAIN_OBJECT_ERROR_TYPE = 'core.dto.invalid_plain_object'; +export type DtoType = HObjectType; -/** - * Alias type - */ -export const TT = TransformationType; -/** - * Is Class to plain transform - * @param type - * @returns - */ -export const isPT = (type: TransformationType): boolean => type === TransformationType.CLASS_TO_PLAIN; - -export function ValueObjectTransformer>(t: { c: (value: any) => Result }): any { - return Transform(({ value, type }) => { - if (value) { - if (isPT(type)) { - return Array.isArray(value) ? value.map((v) => v.toJSON()) : value.toJSON(); - } else { - return Array.isArray(value) ? value.map((v) => (t as any).c(v)) : (t as any).c(value); - } - } - - return undefined; - }); -} - -export function DtoTransformer(t: { new (): T }): any { - return Transform(({ value, type }) => { - if (value) { - if (isPT(type)) { - return Array.isArray(value) ? value.map((v) => v.toJSON()) : value.toJSON(); - } else { - return Array.isArray(value) ? value.map((v) => (t as any).fromPlain(v)) : (t as any).fromPlain(value); - } - } - - return undefined; - }); -} - -export function BigIntTransformer(): any { - return Transform(({ value, type }) => { - if (value) { - if (isPT(type)) { - return Array.isArray(value) ? value.map((v) => v.toString()) : value.toString(); - } else { - return Array.isArray(value) ? value.map((v) => BigInt(v)) : BigInt(value); - } - } - - return undefined; - }); -} - -export type DtoConstructor = { - new (props?: Partial): T; -}; - -/** - * Class with support for transform plain json object to object with rich types like ValueObject - */ export abstract class Dto implements JsonSerialize { + /** * Create from safe props * @param this * @param props * @returns */ - public static cs(this: DtoConstructor, props: Partial): T { + public static cs(this: DtoType, props: NonMethodProperties): T { const i = new this(); Object.assign(i, props); return i; } /** - * Creates DTO from plain form using defined transformators on props + * Creates DTO from plain object * @param this * @param plain * @returns */ - public static fromPlain(this: DtoConstructor, plain: any): Result { - try { - const i: any = plainToInstance(this, plain); - return (this as any).processFromPlain(i); - } catch (e) { - return ERR(INTERNAL_ERROR(e as any)); - } + public static parse(this: PlainParsableHObjectType, plain: unknown): R { + throw new LogicError('Not implemented or AOT generated'); } - /** - * @param i - * @returns Result of tranformation from plain - */ - protected static processFromPlain(this: DtoConstructor, i: any): Result { - const errors: any[] = []; - - for (const p in i) { - const v = i[p]; - if (v instanceof Result) { - if (v.isError()) { - errors.push(v.e); - } else { - i[p] = v.v; - } - } - } - if (errors.length > 0) { - return ERR(INVALID_PLAIN_OBJECT_ERROR_TYPE, 400, { - className: this.name, - errors, - }); - } - return OK(i); + public toJSON(): Record { + throw new LogicError('Not implemented or AOT generated'); } - public toJSON(): any { - return instanceToPlain(this); - } } diff --git a/src/Util/Feature/HObjectTypeMeta.ts b/src/Util/Feature/HObjectTypeMeta.ts new file mode 100644 index 0000000..f3d0e96 --- /dev/null +++ b/src/Util/Feature/HObjectTypeMeta.ts @@ -0,0 +1,92 @@ +import { LogicError } from "../Error"; +import { pascalCaseToSnakeCase } from "../functions"; +import type { JsonSerialize } from "../Json/JsonSerialize"; +import type { R } from "../Result"; +import type { JsonExcluded, JsonObjectType } from "../types"; +import type { HFeatureBackendLayer } from "./types"; + +export type HObjectType = { + new(...args: any[]): T; +}; + +export type PlainParsableHObjectType = { + new(...args: any[]): T; + parse(this: PlainParsableHObjectType, plain: unknown): R; +}; + + +export type HObjectTypeMetaAware = { + new(...args: any[]): T; + HOBJ_META: HObjectTypeMeta; +}; + +export type AnyHObjectType = HObjectType; + +export class HObjectTypeMeta implements JsonSerialize { + public readonly typeId: string; + + public constructor( + public readonly feature: string, + public readonly layer: HFeatureBackendLayer, + public readonly context: string, + public readonly kind: string, + public readonly name: string, + public readonly typeClass: AnyHObjectType & JsonExcluded, + ) { + this.typeId = [ + pascalCaseToSnakeCase(feature), + layer, + pascalCaseToSnakeCase(context), + kind, + pascalCaseToSnakeCase(name) + ].join('.'); + } + + public static application(feature: string, context: string, kind: string, name: string, typeClass: AnyHObjectType) { + return new this(feature, 'application', context, kind, name, typeClass); + } + + public static domain(feature: string, context: string, kind: string, name: string, typeClass: AnyHObjectType) { + return new this(feature, 'domain', context, kind, name, typeClass); + } + + public static infrastructure(feature: string, context: string, kind: string, name: string, typeClass: AnyHObjectType) { + return new this(feature, 'infrastructure', context, kind, name, typeClass); + } + + public static injectToClass(classConstructor: HObjectType, meta: HObjectTypeMeta): void { + (classConstructor as any).HOBJ_META = meta; + } + + public static isHObject(obj: any): boolean { + return obj.constructor.HOBJ_META !== undefined; + } + + public static extractFromClass(classConstructor: HObjectType): HObjectTypeMeta { + const meta = (classConstructor as any).HOBJ_META; + if (!meta) { + throw new LogicError(`Undefined HObject meta on class: '${classConstructor.name}'`); + } + + return meta; + } + + public static extractFromObject(obj: any): HObjectTypeMeta { + return this.extractFromClass(obj.constructor); + } + + public toJSON(): JsonObjectType { + return { + typeId: this.typeId, + feature: this.feature, + layer: this.layer, + context: this.context, + kind: this.kind, + name: this.name, + }; + } + + public toString(): string { + return `HObjectMeta[${this.typeId}]`; + } +} diff --git a/src/Util/Feature/index.ts b/src/Util/Feature/index.ts new file mode 100644 index 0000000..af4f465 --- /dev/null +++ b/src/Util/Feature/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './HObjectTypeMeta'; \ No newline at end of file diff --git a/src/Util/Feature/types.ts b/src/Util/Feature/types.ts new file mode 100644 index 0000000..2687793 --- /dev/null +++ b/src/Util/Feature/types.ts @@ -0,0 +1 @@ +export type HFeatureBackendLayer = 'application' | 'domain' | 'infrastructure'; diff --git a/src/Util/Plain/PlainParseHelper.ts b/src/Util/Plain/PlainParseHelper.ts new file mode 100644 index 0000000..52346a8 --- /dev/null +++ b/src/Util/Plain/PlainParseHelper.ts @@ -0,0 +1,171 @@ +import { AppErrorCode } from "../Error/AppError"; +import { HObjectTypeMeta, type HObjectType, type PlainParsableHObjectType } from "../Feature"; +import { ERR, type R } from "../Result"; +import { InvalidArrayElementsPlainParseIssue, InvalidHObjectPlainParseIssue as InvalidHObjectPlainParseIssue, InvalidTypePlainParseIssue, PlainParseIssue } from "./PlainParseIssue"; + +export const PlainParseError = 'core.plain.parse' as const; +export type PlainParseError = typeof PlainParseError; + +/** + -9223372036854775808 + 9223372036854775807 + */ +const BigInt64Regex = /^-?\d{1,19}$/; + +export class PlainParseHelper { + + public static HObjectParseErr(hcObjectClass: HObjectType, issues: PlainParseIssue[]): R { + const meta = HObjectTypeMeta.extractFromClass(hcObjectClass); + return ERR(PlainParseError, AppErrorCode.BAD_REQUEST, new InvalidHObjectPlainParseIssue(meta, issues)); + } + + public static parseBigInt64(plain: unknown, path?: string, issues?: PlainParseIssue[]): bigint | PlainParseIssue { + if (typeof plain === 'number' || (typeof plain === 'string' && BigInt64Regex.test(plain))) { + return BigInt(plain); + } + + const issue = new InvalidTypePlainParseIssue('bigint_string', typeof plain, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + + public static parseNumber(plain: unknown, path?: string, issues?: PlainParseIssue[]): number | PlainParseIssue { + if (typeof plain === 'number') { + return plain; + } + + const issue = new InvalidTypePlainParseIssue('number', typeof plain, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + + public static parseString(plain: unknown, path?: string, issues?: PlainParseIssue[]): string | PlainParseIssue { + if (typeof plain === 'string') { + return plain; + } + + const issue = new InvalidTypePlainParseIssue('string', typeof plain, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + + public static parseBoolean(plain: unknown, path?: string, issues?: PlainParseIssue[]): boolean | PlainParseIssue { + if (typeof plain === 'boolean') { + return plain; + } + + const issue = new InvalidTypePlainParseIssue('boolean', typeof plain, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + + public static parseHObject(plain: unknown, objectClass: PlainParsableHObjectType, path?: string, issues?: PlainParseIssue[]): PlainParseIssue | T { + const parsed = objectClass.parse(plain); + if (parsed.isError()) { + const issue = parsed.e.data as InvalidHObjectPlainParseIssue; + issue.path = path; + + if (issues) { + issues.push(issue); + } + return issue; + } + + return parsed.v; + } + + public static parsePrimitiveArray(plain: unknown, parse: (v: unknown) => T | PlainParseIssue, path?: string, issues?: PlainParseIssue[]): PlainParseIssue | T[] { + if (!Array.isArray(plain)) { + const issue = new InvalidTypePlainParseIssue('array', typeof plain, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + + let localIssues: PlainParseIssue[] | null = null; + const parsedValues: T[] = []; + for (let i = 0; i < plain.length; i++) { + const parsed = parse(plain[i]); + + if (parsed instanceof PlainParseIssue) { + parsed.path = '' + i; + if (!localIssues) { + localIssues = [parsed]; + } else { + localIssues.push(parsed); + } + continue; + } + + if (!localIssues) { + parsedValues.push(parsed); + } + } + + if (localIssues) { + const issue = new InvalidArrayElementsPlainParseIssue(localIssues, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + return parsedValues; + } + + public static parseHObjectArray(plain: unknown, objectClass: PlainParsableHObjectType, path?: string, issues?: PlainParseIssue[]): PlainParseIssue | T[] { + if (!Array.isArray(plain)) { + const issue = new InvalidTypePlainParseIssue('array', typeof plain, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + + let localIssues: PlainParseIssue[] | null = null; + const parsedValues: T[] = []; + for (let i = 0; i < plain.length; i++) { + const parsed = objectClass.parse(plain[i]); + + if (parsed.isError()) { + const issue = parsed.e.data as InvalidHObjectPlainParseIssue; + issue.path = '' + i; + if (!localIssues) { + localIssues = [issue]; + } else { + localIssues.push(issue); + } + continue; + } + + if (!localIssues) { + parsedValues.push(parsed.v); + } + } + + if (localIssues) { + const issue = new InvalidArrayElementsPlainParseIssue(localIssues, path); + if (issues) { + issues.push(issue); + } + + return issue; + } + return parsedValues; + } +} diff --git a/src/Util/Plain/PlainParseIssue.ts b/src/Util/Plain/PlainParseIssue.ts new file mode 100644 index 0000000..c2f0fed --- /dev/null +++ b/src/Util/Plain/PlainParseIssue.ts @@ -0,0 +1,341 @@ +import type { HObjectTypeMeta } from "../Feature"; +import type { JsonSerialize } from "../Json/JsonSerialize"; +import type { JsonExcluded, JsonObjectType } from "../types"; + + +export enum PlainParseIssueCode { + invalid_type = 'invalid_type', + invalid_string = 'invalid_string', + too_small = 'too_small', + too_big = 'too_big', + invalid_enum_value = 'invalid_enum_value', + invalid_array_elements = 'invalid_array_elements', + invalid_hobject = 'invalid_hobject', + custom = 'custom', +} + +export abstract class PlainParseIssue implements JsonSerialize { + public constructor( + public code: PlainParseIssueCode, + public message: string, + public path?: string + ) { + + } + + public abstract toJSON(): JsonObjectType; + + public get i18n(): string { + return `core.plain.parse_issue.${this.code}`; + } + + public isInvalidType(): this is InvalidTypePlainParseIssue { + return this.code === PlainParseIssueCode.invalid_type; + } + + public isInvalidString(): this is InvalidStringPlainParseIssue { + return this.code === PlainParseIssueCode.invalid_string; + } + + public isTooSmall(): this is TooSmallPlainParseIssue { + return this.code === PlainParseIssueCode.too_small; + } + + public isTooBig(): this is TooBigPlainParseIssue { + return this.code === PlainParseIssueCode.too_big; + } + + public isInvalidEnumValue(): this is InvalidEnumValuePlainParseIssue { + return this.code === PlainParseIssueCode.invalid_enum_value; + } + + public isInvalidArrayElements(): this is InvalidArrayElementsPlainParseIssue { + return this.code === PlainParseIssueCode.invalid_array_elements; + } + + public isInvalidHObject(): this is InvalidHObjectPlainParseIssue { + return this.code === PlainParseIssueCode.invalid_hobject; + } + + public isCustom(): this is CustomPlainParseIssue { + return this.code === PlainParseIssueCode.custom; + } +} + +export type PlainParsePrimitiveType = 'string' | 'number' | 'bigint' | 'bigint_string' | 'boolean' | 'object' | 'array' | 'symbol' | 'undefined' | 'null' | 'function'; + +export class InvalidTypePlainParseIssue extends PlainParseIssue { + public constructor( + public expected: PlainParsePrimitiveType | PlainParsePrimitiveType[], + public received: PlainParsePrimitiveType, + path?: string, + ) { + super(PlainParseIssueCode.invalid_type, `Expected value type: '${Array.isArray(expected) ? expected.join(' | ') : expected}', received: '${received}'`, path); + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + expected: this.expected, + received: this.received, + i18n: this.i18n + }; + } +} + +export class InvalidStringPlainParseIssue extends PlainParseIssue { + public constructor( + public validatorType: string, + message: string, + path?: string, + ) { + super(PlainParseIssueCode.invalid_string, message, path); + } + + public get i18n(): string { + return super.i18n + `.${this.validatorType}`; + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + validatorType: this.validatorType, + i18n: this.i18n + }; + } +} + +export enum ValueRangeSideMode { + number_exclusive = 'number_exclusive', + number_inclusive = 'number_inclusive', + number_exactly = 'number_exactly', + + array_exactly_size = 'array_exactly_size', + array_inclusive_size = 'array_inclusive_size', + + string_exactly_len = 'string_exactly_len', + string_inclusive_len = 'string_inclusive_len', +} + +export class TooSmallPlainParseIssue extends PlainParseIssue { + public constructor( + public minimum: number, + public mode: ValueRangeSideMode, + public current: number, + message: string, + path?: string + ) { + super(PlainParseIssueCode.too_small, message, path); + } + + public static arrayExactlySize(minimum: number, current: number, path?: string): TooSmallPlainParseIssue { + const message = `Array must contain exactly ${minimum} element(s), current: ${current}`; + return new this(minimum, ValueRangeSideMode.array_exactly_size, current, message, path); + } + + public static arrayAtLeastSize(minimum: number, current: number, path?: string): TooSmallPlainParseIssue { + const message = `Array must contain at least ${minimum} element(s), current: ${current}`; + return new this(minimum, ValueRangeSideMode.array_inclusive_size, current, message, path); + } + + public static stringExactlyLen(minimum: number, current: number, path?: string): TooSmallPlainParseIssue { + const message = `String must contain exactly ${minimum} character(s), current: ${current}`; + return new this(minimum, ValueRangeSideMode.string_exactly_len, current, message, path); + } + + public static stringAtLeastLen(minimum: number, current: number, path?: string): TooSmallPlainParseIssue { + const message = `String must contain at least ${minimum} character(s), current: ${current}`; + return new this(minimum, ValueRangeSideMode.string_inclusive_len, current, message, path); + } + + public static numberGTE(minimum: number, current: number, path?: string): TooSmallPlainParseIssue { + const message = `Number must be greater than or equal to ${minimum}, current: ${current}`; + return new this(minimum, ValueRangeSideMode.number_inclusive, current, message, path); + } + + public static numberGT(minimum: number, current: number, path?: string): TooSmallPlainParseIssue { + const message = `Number must be greater than ${minimum}, current: ${current}`; + return new this(minimum, ValueRangeSideMode.number_exclusive, current, message, path); + } + + public static numberExactly(minimum: number, current: number, path?: string): TooSmallPlainParseIssue { + const message = `Number must be exactly ${minimum}, current: ${current}`; + return new this(minimum, ValueRangeSideMode.number_exactly, current, message, path); + } + + public get i18n(): string { + return super.i18n + `.${this.mode}`; + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + i18n: this.i18n, + + minimum: this.minimum, + mode: this.mode, + current: this.current, + }; + } +} + +export class TooBigPlainParseIssue extends PlainParseIssue { + public constructor( + public maximum: number, + public mode: ValueRangeSideMode, + public current: number, + message: string, + path?: string + ) { + super(PlainParseIssueCode.too_small, message, path); + } + + public static arrayExactlySize(maximum: number, current: number, path?: string): TooBigPlainParseIssue { + const message = `Array must contain exactly ${maximum} element(s), current: ${current}`; + return new this(maximum, ValueRangeSideMode.array_exactly_size, current, message, path); + } + + public static arrayAtLeastSize(maximum: number, current: number, path?: string): TooBigPlainParseIssue { + const message = `Array must contain at least ${maximum} element(s), current: ${current}`; + return new this(maximum, ValueRangeSideMode.array_inclusive_size, current, message, path); + } + + public static stringExactlyLen(maximum: number, current: number, path?: string): TooBigPlainParseIssue { + const message = `String must contain exactly ${maximum} character(s), current: ${current}`; + return new this(maximum, ValueRangeSideMode.string_exactly_len, current, message, path); + } + + public static stringAtLeastLen(maximum: number, current: number, path?: string): TooBigPlainParseIssue { + const message = `String must contain maximum ${maximum} character(s), current: ${current}`; + return new this(maximum, ValueRangeSideMode.string_inclusive_len, current, message, path); + } + + public static numberLTE(maximum: number, current: number, path?: string): TooBigPlainParseIssue { + const message = `Number must be less than or equal to ${maximum}, current: ${current}`; + return new this(maximum, ValueRangeSideMode.number_inclusive, current, message, path); + } + + public static numberLT(maximum: number, current: number, path?: string): TooBigPlainParseIssue { + const message = `Number must be less than ${maximum}, current: ${current}`; + return new this(maximum, ValueRangeSideMode.number_exclusive, current, message, path); + } + + public static numberExactly(maximum: number, current: number, path?: string): TooBigPlainParseIssue { + const message = ` Number must be exactly ${maximum}, current: ${current}`; + return new this(maximum, ValueRangeSideMode.number_exactly, current, message, path); + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + i18n: this.i18n, + + maximum: this.maximum, + mode: this.mode, + current: this.current, + }; + } +} + +export class InvalidEnumValuePlainParseIssue extends PlainParseIssue { + public constructor( + public options: (string | number)[], + public received: string | number, + path?: string + ) { + const optionsString = options.map(o => `'${o}'`).join('|'); + super(PlainParseIssueCode.invalid_array_elements, `Invalid enum value. Expected ${optionsString}, received '${received}'`, path); + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + i18n: this.i18n, + + options: this.options, + received: this.received, + }; + } +} + + +export class InvalidArrayElementsPlainParseIssue extends PlainParseIssue { + public constructor( + public issues: PlainParseIssue[], + path?: string + ) { + super(PlainParseIssueCode.invalid_array_elements, 'Invalid array elements', path); + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + i18n: this.i18n, + + issues: this.issues.map(v => v.toJSON() as any), + }; + } +} + +export class InvalidHObjectPlainParseIssue extends PlainParseIssue { + public constructor( + public typeMeta: HObjectTypeMeta & JsonExcluded, + public issues: PlainParseIssue[], + path?: string + ) { + super(PlainParseIssueCode.invalid_hobject, `Invalid object of type: ${typeMeta.typeId}`, path); + } + + public toJSON(): JsonObjectType & { typeId: string; } { + return { + code: this.code, + message: this.message, + path: this.path, + i18n: this.i18n, + typeId: this.typeMeta.typeId, + issues: this.issues.map(v => v.toJSON() as any), + }; + } +} + +export class CustomPlainParseIssue
extends PlainParseIssue { + public constructor( + public details: DT, + message: string, + path?: string + ) { + super(PlainParseIssueCode.custom, message, path); + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + i18n: this.i18n, + details: this.details + }; + } +} + +export type PlainParseIssueType = + | InvalidTypePlainParseIssue + | InvalidStringPlainParseIssue + | TooSmallPlainParseIssue + | TooBigPlainParseIssue + | InvalidEnumValuePlainParseIssue + | InvalidArrayElementsPlainParseIssue + | InvalidHObjectPlainParseIssue + | CustomPlainParseIssue; \ No newline at end of file diff --git a/src/Util/Plain/index.ts b/src/Util/Plain/index.ts new file mode 100644 index 0000000..4779fd1 --- /dev/null +++ b/src/Util/Plain/index.ts @@ -0,0 +1,2 @@ +export * from './PlainParseHelper'; +export * from './PlainParseIssue'; \ No newline at end of file diff --git a/src/Util/Plain/types.ts b/src/Util/Plain/types.ts new file mode 100644 index 0000000..ebc7ed6 --- /dev/null +++ b/src/Util/Plain/types.ts @@ -0,0 +1,59 @@ + +export type ValueRuleTagBase = { + "__hctag"?: Meta; +}; + +type NumberValueRuleTag = number & ValueRuleTagBase; + +export namespace v { + export namespace string { + export type pattern = string & ValueRuleTagBase<'string_pattern'>; + + export type length = string & ValueRuleTagBase<'string_length_exactly'>; + export namespace length { + export type min = string & ValueRuleTagBase<'string_length_min'>; + export type max = string & ValueRuleTagBase<'string_length_max'>; + export type between = string & ValueRuleTagBase<'string_length_between'>; + } + } + + export type float = NumberValueRuleTag<'float'>; + export namespace float { + export type min = NumberValueRuleTag<'float_min'>; + export type max = NumberValueRuleTag<'float_max'>; + export type between = NumberValueRuleTag<'float_between'>; + + export type gt = NumberValueRuleTag<'float_gt'>; + export type lt = NumberValueRuleTag<'float_lt'>; + export type between_exclusive = NumberValueRuleTag<'float_between_exclusive'>; + } + + export type int = NumberValueRuleTag<'int'>; + export namespace int { + export type min = NumberValueRuleTag<'int_min'>; + export type max = NumberValueRuleTag<'int_max'>; + export type between = NumberValueRuleTag<'int_between'>; + + export type between_exclusive = NumberValueRuleTag<'int_between_exclusive'>; + export type gt = NumberValueRuleTag<'int_gt'>; + export type lt = NumberValueRuleTag<'int_lt'>; + } + + export type uint = number & ValueRuleTagBase<'uint'>; + export namespace uint { + export type min = NumberValueRuleTag<'uint_min'>; + export type max = number & ValueRuleTagBase<'uint_max'>; + export type between = NumberValueRuleTag<'uint_between'>; + + export type between_exclusive = NumberValueRuleTag<'uint_between_exclusive'>; + export type gt = NumberValueRuleTag<'uint_gt'>; + export type lt = NumberValueRuleTag<'uint_lt'>; + } + + export namespace items { + export type exactly = ValueRuleTagBase<'items_exactly'>; + export type min = ValueRuleTagBase<'items_min'>; + export type max = ValueRuleTagBase<'items_max'>; + export type between = ValueRuleTagBase<'items_between'>; + } +} \ No newline at end of file diff --git a/src/Util/functions.ts b/src/Util/functions.ts index 823c597..74eb4b6 100644 --- a/src/Util/functions.ts +++ b/src/Util/functions.ts @@ -14,8 +14,11 @@ export function getClassMethods(obj: Record): Array { return [...properties.keys()].filter((item: string) => typeof obj[item] === 'function'); } -export function pascalCaseToSnakeCase(s: string): string { - return s.replace(/(?:^|\.?)([A-Z])/g, (_x, y) => '_' + y.toLowerCase()).replace(/^_/, ''); +export function pascalCaseToSnakeCase(input: string): string { + return input + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') // "XMLHttpRequest" => 'XML_Http_Request' + .toLowerCase(); } export function pascalCaseToCamelCase(s: string): string { diff --git a/src/Util/index.ts b/src/Util/index.ts index 9333a32..bb95ab8 100644 --- a/src/Util/index.ts +++ b/src/Util/index.ts @@ -3,6 +3,10 @@ export * from './Error'; export * from './Result'; +export * from './Plain'; + +export * from './Feature'; + export * from './CurrentTime'; export * from './RetryHelper'; export * from './QueryHelper'; diff --git a/src/Util/types.ts b/src/Util/types.ts index d0a6102..e5ad38b 100644 --- a/src/Util/types.ts +++ b/src/Util/types.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/ban-types */ +import type { JsonSerialize } from "./Json/JsonSerialize"; + export type DeepPartial = Partial<{ [P in keyof T]: DeepPartial }>; /** @@ -51,3 +53,59 @@ export type CastVoidToUnknownMarker = U extends void ? TUNKNOWN : U; export type CastToIterable = T extends Iterable ? T : never; export type ExtractKeyof = K extends keyof U ? K : never; + + +/** + * Default exclude tag type + */ +export type NO_EXCLUDE_TAG = TUNKNOWN; + +/** + * Extracts all not method required properties names. Optional checks exclude tag + */ +export type NonMethodRequiredPropertyNames = Exclude<{ + [K in keyof T]: T[K] extends Function ? never : (T[K] extends ExcludeTag ? never : (T[K] extends {} ? K : never)); +}[keyof T], undefined>; + +/** + * Extracts all not method required properties names. Optional checks exclude tag + */ +export type NonMethodOptionalPropertyNames = Exclude<{ + [K in keyof T]: T[K] extends Function ? never : (T[K] extends ExcludeTag ? never : (T[K] extends {} ? never : K)); +}[keyof T], undefined>; + + +/** + * Extracts all not function(method) properties. Optional checks exclude tag + */ +export type NonMethodProperties = Pick> & Pick>; + +export type ToJSONReturnType = T extends JsonSerialize ? ReturnType : never; + +// Determines the plain field type of a property +type PlainFieldType = + T extends BigInt ? string + : T extends Array ? Array> + : T extends Map ? [K, V][] + : T extends Set ? V[] + : T extends JsonSerialize ? ToJSONReturnType + : T; + +/** + * Converts all object properties(deeply) to plain primitives type + */ +export type PlainObjectType = { + [K in NonMethodRequiredPropertyNames]: PlainFieldType +} & { + [K in NonMethodOptionalPropertyNames]+?: PlainFieldType | undefined +}; + +/** + * Tag to mark property in object as not json serializable. + */ +export type JsonExcluded = { __hctag?: 'json_excluded'; }; + +/** + * Converts all object properties(deeply) to plain primitives. Skips all with tag type: `JsonExcluded` + */ +export type JsonObjectType = PlainObjectType; diff --git a/test/helper/TestDto.ts b/test/helper/TestDto.ts new file mode 100644 index 0000000..d41aced --- /dev/null +++ b/test/helper/TestDto.ts @@ -0,0 +1,154 @@ +import { + ValueObject, + UIntValue, Dto, + type JsonObjectType, + type PlainParsableHObjectType, + type R, type PlainParseError, + PlainParseHelper, + InvalidTypePlainParseIssue, + OK, + HObjectTypeMeta, + PlainParseIssue, + TooBigPlainParseIssue, + TooSmallPlainParseIssue, +} from "@"; +import type { v } from "@/Util/Plain/types"; + +@ValueObject('Test') +export class TestValueObject extends UIntValue { } + +class OtherTestDto extends Dto { + public primitiveField!: v.int.between<10, 100>; + + // generate constructor in AOT + public constructor( + primitiveField: number + ) { + super(); + this.primitiveField = primitiveField; + } + + + public static parse(this: PlainParsableHObjectType, plain: unknown): R { + // constant check part + if (typeof plain !== 'object') { + return PlainParseHelper.HObjectParseErr(this, [new InvalidTypePlainParseIssue('object', typeof plain)]); + } + + const plainObj = plain as Record; + const issues: PlainParseIssue[] = []; + // end constant check part + + const primitiveField = PlainParseHelper.parseNumber(plainObj.primitiveField, 'primitiveField', issues); + if (!(primitiveField instanceof PlainParseIssue)) { + if (primitiveField < 10) { + issues.push(TooSmallPlainParseIssue.numberGTE(10, primitiveField)); + } + + if (primitiveField > 100) { + issues.push(TooBigPlainParseIssue.numberLTE(100, primitiveField)); + } + } + + if (issues.length > 0) { + return PlainParseHelper.HObjectParseErr(this, issues); + } + + return OK(new this( + primitiveField + )); + } + + public toJSON(): JsonObjectType { + return { + primitiveField: this.primitiveField, + }; + } +} + +export class TestDto extends Dto { + public static HOBJ_META = HObjectTypeMeta.application('core', 'core', 'dto', 'TestDto', TestDto); + + public constructor( + public bigIntField: bigint, + public numberField: v.uint, + public numberArrayField: number[], + public booleanField: boolean, + + public optionalValueObjectField?: TestValueObject, + public optionalValueObjectArrayField?: TestValueObject[], + public optionalDtoField?: OtherTestDto, + public optionalDtoArrayField?: OtherTestDto[], + + ) { + super(); + } + + public static parse(this: PlainParsableHObjectType, plain: unknown): R { + // constant check part + if (typeof plain !== 'object') { + return PlainParseHelper.HObjectParseErr(this, [new InvalidTypePlainParseIssue('object', typeof plain)]); + } + + const plainObj = plain as Record; + const issues: PlainParseIssue[] = []; + // end constant check part + + const bigIntField = PlainParseHelper.parseBigInt64(plainObj.bigIntField, 'bigIntField', issues); + const numberField = PlainParseHelper.parseNumber(plainObj.numberField, 'numberField', issues); + if (!(numberField instanceof PlainParseIssue) && numberField < 0) { + issues.push(TooSmallPlainParseIssue.numberGTE(0, numberField, 'numberField')); + } + const numberArrayField = PlainParseHelper.parsePrimitiveArray(plainObj.numberArrayField, PlainParseHelper.parseNumber, 'numberArrayField', issues); + const booleanField = PlainParseHelper.parseBoolean(plainObj.booleanField, 'booleanField', issues); + + let valueObjectField; + if (plainObj.optionalValueObjectField !== undefined) { + valueObjectField = PlainParseHelper.parseHObject(plainObj.optionalValueObjectField, TestValueObject, 'optionalValueObjectField', issues); + } + + let optionalValueObjectArrayField; + if (plainObj.optionalValueObjectArrayField !== undefined) { + optionalValueObjectArrayField = PlainParseHelper.parseHObjectArray(plainObj.optionalValueObjectArrayField, TestValueObject, 'optionalValueObjectArrayField', issues); + } + + let optionalDtoField; + if (plainObj.optionalDtoField !== undefined) { + optionalDtoField = PlainParseHelper.parseHObject(plainObj.optionalDtoField, TestValueObject, 'optionalDtoField', issues); + } + + let optionalDtoArrayField; + if (plainObj.optionalDtoArrayField !== undefined) { + optionalDtoArrayField = PlainParseHelper.parseHObjectArray(plainObj.optionalDtoArrayField, OtherTestDto, 'optionalDtoArrayField', issues); + } + + if (issues.length > 0) { + return PlainParseHelper.HObjectParseErr(this, issues); + } + + return OK(new this( + bigIntField, + numberField, + numberArrayField, + booleanField, + valueObjectField, + optionalValueObjectArrayField, + optionalDtoField, + optionalDtoArrayField + )); + } + + + public toJSON(): JsonObjectType { + return { + bigIntField: this.bigIntField.toString(), + numberField: this.numberField, + numberArrayField: this.numberArrayField, + booleanField: this.booleanField, + optionalValueObjectField: this.optionalValueObjectField?.toJSON(), + optionalValueObjectArrayField: this.optionalValueObjectArrayField?.map(v => v.toJSON()), + optionalDtoField: this.optionalDtoField?.toJSON(), + optionalDtoArrayField: this.optionalDtoArrayField?.map((v) => v.toJSON()), + }; + } +} \ No newline at end of file diff --git a/test/unit/Util/Dto.test.ts b/test/unit/Util/Dto.test.ts index 2060345..a076d79 100644 --- a/test/unit/Util/Dto.test.ts +++ b/test/unit/Util/Dto.test.ts @@ -1,4 +1,4 @@ -import { BigIntTransformer, Dto, DtoTransformer, UIntValue, ValueObject, ValueObjectTransformer } from '@'; +import { Dto, UIntValue, ValueObject, type JsonObjectType } from '@'; import path from 'path'; /** @@ -6,35 +6,50 @@ import path from 'path'; */ @ValueObject('Test') -class TestValueObject extends UIntValue {} +class TestValueObject extends UIntValue { } class OtherTestDto extends Dto { - public primitiveField?: number; + public constructor( + public primitiveField?: number + ) { + super(); + } + + public toJSON(): JsonObjectType { + return { + primitiveField: this.primitiveField, + } as JsonObjectType; + } } class TestDto extends Dto { - public primitiveField?: number; - - @BigIntTransformer() - public bigIntField!: bigint; - - @BigIntTransformer() - public bigIntArrayField!: bigint[]; - - @ValueObjectTransformer(TestValueObject) - public valueObjectField?: TestValueObject; - - @ValueObjectTransformer(TestValueObject) - public valueObjectArrayField?: TestValueObject[]; + public constructor( + public bigIntField: bigint, + public bigIntArrayField: bigint[], + public valueObjectField?: TestValueObject, + public valueObjectArrayField?: TestValueObject[], + public dtoField?: OtherTestDto, + public dtoArrayField?: OtherTestDto[], + public primitiveField?: number, + ) { + super(); + } - @DtoTransformer(OtherTestDto) - public dtoField?: OtherTestDto; - @DtoTransformer(OtherTestDto) - public dtoArrayField?: OtherTestDto[]; + public toJSON(): JsonObjectType { + return { + primitiveField: this.primitiveField, + bigIntField: this.bigIntField.toString(), + bigIntArrayField: this.bigIntArrayField.map(v => v.toString()), + valueObjectField: this.valueObjectField, + valueObjectArrayField: this.valueObjectArrayField, + dtoField: this.dtoField?.toJSON(), + dtoArrayField: this.dtoArrayField?.map((v) => v.toJSON()), + } as JsonObjectType; + } } -describe(path.basename(__filename, '.test.ts'), () => { +describe.skip(path.basename(__filename, '.test.ts'), () => { test('toJSON', () => { const dto = TestDto.cs({ primitiveField: 1, diff --git a/test/unit/Util/Plain/ObjectPropertyTypes.test.ts b/test/unit/Util/Plain/ObjectPropertyTypes.test.ts new file mode 100644 index 0000000..f1cc18f --- /dev/null +++ b/test/unit/Util/Plain/ObjectPropertyTypes.test.ts @@ -0,0 +1,61 @@ +import type { v } from '@/Util/Plain/types'; +import path from 'node:path'; + +class Super { + public constructor( + public stringPatternField: v.string.pattern<'^[A-Z][a-zA-Z]{4}$'>, + + public stringLengthField: v.string.length<10>, + public stringMinField: v.string.length.min<10>, + public stringMaxField: v.string.length.max<10>, + public stringBetweenField: v.string.length.between<10, 50>, + + public intField: v.int, + + public intMinField: v.int.min<-10>, + public intMaxField: v.int.max<100>, + public intBetweenField: v.int.between<-10, 100>, + + public intLtField: v.int.lt<-10>, + public intGtField: v.int.gt<100>, + public intBetweenExclusiveField: v.int.between_exclusive<-10, 100>, + + public uintField: v.uint, + + public uintMinField: v.uint.min<10>, + public uintMaxField: v.uint.max<100>, + public uintBetweenField: v.uint.between<10, 100>, + + public uintLtField: v.uint.lt<10>, + public uintGtField: v.uint.gt<100>, + public uintBetweenExclusiveField: v.uint.between_exclusive<10, 100>, + + public floatField: v.float, + public floatMinField: v.float.min<-1.5>, + public floatMaxField: v.float.max<2.5>, + public floatBetweenField: v.float.between<-1.5, 2.5>, + + public floatLtField: v.float.lt<-1.5>, + public floatGtField: v.float.gt<2.5>, + public floatBetweenExclusiveField: v.float.between_exclusive<-1.5, 2.5>, + + public intArray10: v.int.between<10, 100>[] & v.items.exactly<10>, + public intArrayMin: v.int.between<10, 100>[] & v.items.min<10>, + public intArrayMax: v.int.between<10, 100>[] & v.items.max<10>, + public intArrayBetween: v.int.between<10, 100>[] & v.items.between<10, 20>, + + public intMap10: Map> & v.items.min<10>, + ) { + + } +} + +/** + * @group unit + */ + +describe(path.basename(__filename, '.test.ts'), () => { + test('HObjectParseErr', () => { + expect(1).toBe(1); + }); +}); \ No newline at end of file diff --git a/test/unit/Util/Plain/PlainParseHelper.test.ts b/test/unit/Util/Plain/PlainParseHelper.test.ts new file mode 100644 index 0000000..95c03dc --- /dev/null +++ b/test/unit/Util/Plain/PlainParseHelper.test.ts @@ -0,0 +1,158 @@ +import { AppErrorCode, InvalidArrayElementsPlainParseIssue, InvalidHObjectPlainParseIssue, InvalidTypePlainParseIssue, PlainParseError, PlainParseHelper, type JsonObjectType } from '@'; +import { TestDto } from '@test/helper/TestDto'; +import path from 'node:path'; + +/** + * @group unit + */ + +describe(path.basename(__filename, '.test.ts'), () => { + test('HObjectParseErr', () => { + const issues = [new InvalidTypePlainParseIssue('number', 'string', 'field')]; + const current = PlainParseHelper.HObjectParseErr(TestDto, [new InvalidTypePlainParseIssue('number', 'string', 'field')]); + + expect(current.e.data).toEqual(new InvalidHObjectPlainParseIssue(TestDto.HOBJ_META, issues)); + expect(current.e.type).toBe(PlainParseError); + expect(current.e.code).toBe(AppErrorCode.BAD_REQUEST); + }); + + describe('parseBigInt64()', () => { + test('when valid should return parsed', () => { + const plain = '1000'; + + const current = PlainParseHelper.parseBigInt64(plain); + + expect(current).toBe(1000n); + }); + + test('when invalid should return issue', () => { + const plain = 'bad1000'; + + const current = PlainParseHelper.parseBigInt64(plain); + + expect(current).toEqual(new InvalidTypePlainParseIssue('bigint_string', 'string')); + }); + }); + + describe('parseNumber()', () => { + test('when valid should return parsed', () => { + const plain = 1000; + + const current = PlainParseHelper.parseNumber(plain); + + expect(current).toBe(1000); + }); + + test('when invalid should return issue', () => { + const plain = 'bad1000'; + + const current = PlainParseHelper.parseNumber(plain); + + expect(current).toEqual(new InvalidTypePlainParseIssue('number', 'string')); + }); + }); + + describe('parseBoolean()', () => { + test('when valid should return parsed', () => { + const plain = true; + + const current = PlainParseHelper.parseBoolean(plain); + + expect(current).toBe(true); + }); + + test('when invalid should return issue', () => { + const plain = 1000; + + const current = PlainParseHelper.parseBoolean(plain); + + expect(current).toEqual(new InvalidTypePlainParseIssue('boolean', 'number')); + }); + }); + + describe('parseString()', () => { + test('when valid should return parsed', () => { + const plain = 'good'; + + const current = PlainParseHelper.parseString(plain); + + expect(current).toBe('good'); + }); + + test('when invalid should return issue', () => { + const plain = 1000; + + const current = PlainParseHelper.parseString(plain); + + expect(current).toEqual(new InvalidTypePlainParseIssue('string', 'number')); + }); + }); + + describe('parseHObject()', () => { + test('when valid should return parsed', () => { + const plain: JsonObjectType = { + bigIntField: '1000', + numberField: 1000, + numberArrayField: [1000], + booleanField: true, + }; + + const current = PlainParseHelper.parseHObject(plain, TestDto); + + const expected = TestDto.cs({ + bigIntField: 1000n, + booleanField: true, + numberArrayField: [1000], + numberField: 1000 + }); + expect(current).toEqual(expected); + }); + + test('when invalid should return issue', () => { + const plain = 1000; + + const current = PlainParseHelper.parseHObject(plain, TestDto); + + expect(current).toEqual(new InvalidHObjectPlainParseIssue(TestDto.HOBJ_META, [ + new InvalidTypePlainParseIssue('object', 'number') + ])); + }); + }); + + + describe('parseHObjectArray()', () => { + test('when valid should return parsed', () => { + const plain: JsonObjectType = { + bigIntField: '1000', + numberField: 1000, + numberArrayField: [1000], + booleanField: true, + }; + + const current = PlainParseHelper.parseHObjectArray([plain], TestDto); + + const expected = TestDto.cs({ + bigIntField: 1000n, + booleanField: true, + numberArrayField: [1000], + numberField: 1000 + }); + expect(current).toEqual([expected]); + }); + + test('when invalid should return issue', () => { + const plain = 1000; + + const current = PlainParseHelper.parseHObjectArray([plain], TestDto); + + const expected = new InvalidArrayElementsPlainParseIssue([ + new InvalidHObjectPlainParseIssue(TestDto.HOBJ_META, [ + new InvalidTypePlainParseIssue('object', 'number') + ], '0') + ]) + expect(current).toEqual(expected); + }); + }); + + +});