From f35b96e6d87f3d12d604a49edc0dbe8050549a01 Mon Sep 17 00:00:00 2001 From: Mararok <5163714+Mararok@users.noreply.github.com> Date: Sat, 7 Dec 2024 18:32:49 +0100 Subject: [PATCH] Feat changes pack (#72) * fix(util): add missing additionalProperties to JsonSchemaFactory.Object * feat(util.plain): add MapPlainParseHelper * feat(util.json): add new methods to JsonHelper - mapJsonSerializableMapToJson() - mapPrimitiveMapToJson() - mapJsonSerializableArrayToJson() * chore: update changelog --- CHANGELOG.md | 13 +- src/Util/Json/JsonHelper.ts | 24 +++ src/Util/Json/JsonSchema.ts | 6 +- src/Util/Plain/MapPlainParseHelper.ts | 109 ++++++++++++ src/Util/Plain/PlainParseHelper.ts | 13 ++ src/Util/Plain/PlainParseIssue.ts | 48 +++++- src/Util/Plain/index.ts | 8 +- test/helper/TestDto.ts | 2 + .../Util/Plain/MapPlainParseHelper.test.ts | 155 ++++++++++++++++++ 9 files changed, 369 insertions(+), 9 deletions(-) create mode 100644 src/Util/Plain/MapPlainParseHelper.ts create mode 100644 test/unit/Util/Plain/MapPlainParseHelper.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac666c..5877e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- added MapPlainParseHelper +- added JsonHelper methods: mapJsonSerializableMapToJson(), mapPrimitiveMapToJson(), mapJsonSerializableArrayToJson() + +### Fixed + +- JsonSchemaFactory.Object() add missing 'additionalProperties' option + ## [0.16.4] - 2024-11-30 ### Added @@ -206,8 +215,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - many changes. -[unreleased] https://github.com/hexancore/common/compare/0.16.4...HEAD -[0.16.4] https://github.com/hexancore/common/compare/0.16.3...0.16.4 +[unreleased] https://github.com/hexancore/common/compare/0.16.4...HEAD +[0.16.4] https://github.com/hexancore/common/compare/0.16.3...0.16.4 [0.16.3] https://github.com/hexancore/common/compare/0.16.2...0.16.3 [0.16.2] https://github.com/hexancore/common/compare/0.16.1...0.16.2 [0.16.1] https://github.com/hexancore/common/compare/0.16.0...0.16.1 diff --git a/src/Util/Json/JsonHelper.ts b/src/Util/Json/JsonHelper.ts index 452298d..c67c41f 100644 --- a/src/Util/Json/JsonHelper.ts +++ b/src/Util/Json/JsonHelper.ts @@ -1,5 +1,7 @@ import { AppErrorCode, DefineErrorsUnion } from '../Error'; import { ERR, OK, R } from '../Result/Result'; +import type { JsonObjectType } from "../types"; +import type { JsonSerialize } from "./JsonSerialize"; export type JsonTraverserFn = (this: any, key: string, value: any) => any; @@ -25,4 +27,26 @@ export class JsonHelper { return ERR({ type: JsonErrors.stringify, code: AppErrorCode.BAD_REQUEST, error: e as any }); } } + + public static mapJsonSerializableArrayToJson(input: T[]): JsonObjectType[] { + const out: JsonObjectType[] = []; + input.forEach(v => { + out.push(v.toJSON()); + }); + + return out; + } + + public static mapJsonSerializableMapToJson(input: Map): [K, JsonObjectType][] { + const out: [K, JsonObjectType][] = []; + input.forEach((v, k) => { + out.push([k, v.toJSON()]); + }); + + return out; + } + + public static mapPrimitiveMapToJson(input: Map): [K, V][] { + return Array.from(input.entries()); + } } diff --git a/src/Util/Json/JsonSchema.ts b/src/Util/Json/JsonSchema.ts index 2345e53..da3e92d 100644 --- a/src/Util/Json/JsonSchema.ts +++ b/src/Util/Json/JsonSchema.ts @@ -28,7 +28,7 @@ export interface NumberJsonSchema extends JsonSchemaBase { maximum?: number; exclusiveMaximum?: number; - default?: number; + default?: number; } export interface BooleanJsonSchema extends JsonSchemaBase { @@ -47,7 +47,7 @@ export interface ObjectJsonSchema extends JsonSchemaBase { type: "object"; properties: Record; required?: string[]; - additionalProperties?: boolean + additionalProperties?: boolean; } export type JsonSchema = @@ -85,7 +85,7 @@ export class JsonSchemaFactory { }; } - public static Object(properties: Record, options: Pick = {}): ObjectJsonSchema { + public static Object(properties: Record, options: Pick = {}): ObjectJsonSchema { return { type: "object", properties, diff --git a/src/Util/Plain/MapPlainParseHelper.ts b/src/Util/Plain/MapPlainParseHelper.ts new file mode 100644 index 0000000..5da65b5 --- /dev/null +++ b/src/Util/Plain/MapPlainParseHelper.ts @@ -0,0 +1,109 @@ +import { type PlainParsableHObjectType } from "../Feature/HObjectTypeMeta"; +import { ArrayPlainParseHelper } from "./ArrayPlainParseHelper"; +import { InvalidArrayElementsPlainParseIssue, InvalidHObjectPlainParseIssue, InvalidMapEntriesPlainParseIssue, InvalidMapEntryPlainParseIssue, InvalidTypePlainParseIssue, OutOfRangePlainParseIssue, PlainParseIssue, TooBigPlainParseIssue, TooSmallPlainParseIssue } from "./PlainParseIssue"; + +export type ParseMapEntryKeyFn = (v: unknown) => T | PlainParseIssue; +export type ParseMapEntryValueFn = (v: unknown) => T | PlainParseIssue; + +export class MapPlainParseHelper { + + public static parsePrimitiveMap( + plain: unknown, + parseEntryKey: ParseMapEntryKeyFn, + parseEntryValue: ParseMapEntryValueFn, + path?: string, + issues?: PlainParseIssue[] + ): PlainParseIssue | Map { + if (!Array.isArray(plain)) { + const issue = new InvalidTypePlainParseIssue("map_entries", typeof plain, path); + issues?.push(issue); + return issue; + } + + const entries = ArrayPlainParseHelper.parsePrimitiveArray(plain, (plainEntry) => { + if (!Array.isArray(plainEntry)) { + return new InvalidTypePlainParseIssue("map_entry", typeof plainEntry); + } + + if (plainEntry.length !== 2) { + return new InvalidTypePlainParseIssue("map_entry", "array"); + } + + const key = parseEntryKey(plainEntry[0]); + const value = parseEntryValue(plainEntry[1]); + + let keyIssue, valueIssue; + if (key instanceof PlainParseIssue) { + keyIssue = key; + } + + if (value instanceof PlainParseIssue) { + valueIssue = value; + } + + if (keyIssue || valueIssue) { + return new InvalidMapEntryPlainParseIssue(keyIssue, valueIssue); + } + + return [key, value]; + }); + + if (entries instanceof InvalidArrayElementsPlainParseIssue) { + const issue = new InvalidMapEntriesPlainParseIssue(entries.issues as any, path); + issues?.push(issue); + return issue; + } + + return new Map(entries as any); + } + + public static parseHObjectMap( + plain: unknown, + parseEntryKey: ParseMapEntryKeyFn, + objectClass: PlainParsableHObjectType, + path?: string, + issues?: PlainParseIssue[] + ): PlainParseIssue | Map { + if (!Array.isArray(plain)) { + const issue = new InvalidTypePlainParseIssue("map_entries", typeof plain, path); + issues?.push(issue); + return issue; + } + + const entries = ArrayPlainParseHelper.parsePrimitiveArray(plain, (plainEntry) => { + if (!Array.isArray(plainEntry)) { + return new InvalidTypePlainParseIssue("map_entry", typeof plainEntry); + } + + if (plainEntry.length !== 2) { + return new InvalidTypePlainParseIssue("map_entry", "array"); + } + + const key = parseEntryKey(plainEntry[0]); + const valueParseResult = objectClass.parse(plainEntry[1]); + + let keyIssue, valueIssue; + if (key instanceof PlainParseIssue) { + keyIssue = key; + } + + if (valueParseResult.isError()) { + valueIssue = valueParseResult.e.data as InvalidHObjectPlainParseIssue; + } + + if (keyIssue || valueIssue) { + return new InvalidMapEntryPlainParseIssue(keyIssue, valueIssue); + } + + return [key, valueParseResult.v]; + }); + + if (entries instanceof InvalidArrayElementsPlainParseIssue) { + const issue = new InvalidMapEntriesPlainParseIssue(entries.issues as any, path); + issues?.push(issue); + return issue; + } + + return new Map(entries as any); + } +} \ No newline at end of file diff --git a/src/Util/Plain/PlainParseHelper.ts b/src/Util/Plain/PlainParseHelper.ts index 4770322..b813148 100644 --- a/src/Util/Plain/PlainParseHelper.ts +++ b/src/Util/Plain/PlainParseHelper.ts @@ -103,4 +103,17 @@ export class PlainParseHelper { return issue; } + + public static parseMap>(plain: unknown, path?: string, issues?: PlainParseIssue[]): PlainParseIssue | T { + if (typeof plain === 'object' && plain !== null) { + return plain as T; + } + + const issue = new InvalidTypePlainParseIssue('object', plain === null ? 'null' : typeof plain, path); + if (issues) { + issues.push(issue); + } + + return issue; + } } diff --git a/src/Util/Plain/PlainParseIssue.ts b/src/Util/Plain/PlainParseIssue.ts index f6ecc93..c508542 100644 --- a/src/Util/Plain/PlainParseIssue.ts +++ b/src/Util/Plain/PlainParseIssue.ts @@ -10,6 +10,8 @@ export enum PlainParseIssueCode { out_of_range = 'out_of_range', invalid_enum_value = 'invalid_enum_value', invalid_array_elements = 'invalid_array_elements', + invalid_map_entries = 'invalid_map_entries', + invalid_map_entry = 'invalid_map_entry', invalid_hobject = 'invalid_hobject', custom = 'custom', } @@ -62,7 +64,7 @@ export abstract class PlainParseIssue implements JsonSerialize { } } -export type PlainParsePrimitiveType = 'string' | 'number' | 'int' | 'uint' | 'bigint' | 'bigint_string' | "uint64_string" | 'boolean' | 'object' | 'array' | 'symbol' | 'undefined' | 'null' | 'function' | 'Date'; +export type PlainParsePrimitiveType = 'string' | 'number' | 'int' | 'uint' | 'bigint' | 'bigint_string' | "uint64_string" | 'boolean' | 'object' | 'array' | 'map_entries' | 'map_entry' | 'symbol' | 'undefined' | 'null' | 'function' | 'Date'; export class InvalidTypePlainParseIssue extends PlainParseIssue { public constructor( @@ -96,7 +98,7 @@ export class InvalidStringPlainParseIssue extends PlainParseIssue { } public static uuid(path?: string): InvalidStringPlainParseIssue { - return new this('uuid', { }, "String must be UUID", path); + return new this('uuid', {}, "String must be UUID", path); } public static regex(regex: RegExp, path?: string): InvalidStringPlainParseIssue { @@ -365,6 +367,48 @@ export class InvalidArrayElementsPlainParseIssue extends PlainParseIssue { } } +export class InvalidMapEntryPlainParseIssue extends PlainParseIssue { + public constructor( + public keyIssue?: PlainParseIssue, + public valueIssue?: PlainParseIssue, + path?: string + ) { + super(PlainParseIssueCode.invalid_map_entry, 'Invalid map entry', path); + } + + public toJSON(): JsonObjectType { + return { + code: this.code, + message: this.message, + path: this.path, + i18n: this.i18n, + + keyIssue: this.keyIssue?.toJSON(), + valueIssue: this.valueIssue?.toJSON(), + }; + } +} + +export class InvalidMapEntriesPlainParseIssue extends PlainParseIssue { + public constructor( + public issues: (InvalidMapEntryPlainParseIssue | InvalidTypePlainParseIssue)[], + path?: string + ) { + super(PlainParseIssueCode.invalid_map_entries, 'Invalid map entries', 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, diff --git a/src/Util/Plain/index.ts b/src/Util/Plain/index.ts index 7b888fa..767d97a 100644 --- a/src/Util/Plain/index.ts +++ b/src/Util/Plain/index.ts @@ -1,7 +1,11 @@ export * from './PlainParseIssue'; export * from './types'; + export * from './PlainParseHelper'; + export * from './IntegerPlainParseHelper'; -export * from './ArrayPlainParseHelper'; export * from './StringPlainParseHelper'; -export * from './NumberPlainParseHelper'; \ No newline at end of file +export * from './NumberPlainParseHelper'; + +export * from './ArrayPlainParseHelper'; +export * from './MapPlainParseHelper'; \ No newline at end of file diff --git a/test/helper/TestDto.ts b/test/helper/TestDto.ts index 0e0864f..0368235 100644 --- a/test/helper/TestDto.ts +++ b/test/helper/TestDto.ts @@ -18,6 +18,8 @@ import type { v } from "@/Util/Plain/types"; export class TestValueObject extends UInt { } export class OtherTestDTO extends DTO { + public static HOBJ_META = HObjectTypeMeta.application('core', 'core', 'dto', 'OtherTestDTO', OtherTestDTO); + public primitiveField!: v.int.between<10, 100>; // generate constructor in AOT diff --git a/test/unit/Util/Plain/MapPlainParseHelper.test.ts b/test/unit/Util/Plain/MapPlainParseHelper.test.ts new file mode 100644 index 0000000..0f7aec7 --- /dev/null +++ b/test/unit/Util/Plain/MapPlainParseHelper.test.ts @@ -0,0 +1,155 @@ +import { InvalidHObjectPlainParseIssue, InvalidMapEntriesPlainParseIssue, InvalidMapEntryPlainParseIssue, InvalidTypePlainParseIssue, NumberPlainParseHelper, StringPlainParseHelper } from "@"; +import { MapPlainParseHelper } from "@/Util/Plain/MapPlainParseHelper"; +import { OtherTestDTO } from "@test/helper/TestDto"; +import path from "node:path"; + +/** + * @group unit + */ + +describe(path.basename(__filename, ".test.ts"), () => { + describe("parsePrimitiveMap", () => { + test("when valid", () => { + const entries: [string, number][] = [["key_1", 1], ["key_2", 2]]; + + const current = MapPlainParseHelper.parsePrimitiveMap(entries, StringPlainParseHelper.parseString, NumberPlainParseHelper.parseNumber); + + expect(current).toEqual(new Map(entries)); + + }); + + test("when invalid type", () => { + const entries: unknown = 100; + + const issues = []; + const current = MapPlainParseHelper.parsePrimitiveMap(entries, StringPlainParseHelper.parseString, NumberPlainParseHelper.parseNumber, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidTypePlainParseIssue("map_entries", "number", "map")); + }); + + test("when missing entry value", () => { + const entries: unknown = [["key_1"], ["key_2", 2]]; + + const issues = []; + const current = MapPlainParseHelper.parsePrimitiveMap(entries, StringPlainParseHelper.parseString, NumberPlainParseHelper.parseNumber, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidTypePlainParseIssue("map_entry", "array", "0") + ], "map")); + }); + + test("when missing entry key", () => { + const entries: unknown = [[1], ["key_2", 2]]; + + const issues = []; + const current = MapPlainParseHelper.parsePrimitiveMap(entries, StringPlainParseHelper.parseString, NumberPlainParseHelper.parseNumber, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidTypePlainParseIssue("map_entry", "array", "0") + ], "map")); + }); + + test("when invalid key", () => { + const entries: unknown = [[123, 1], ["key_2", 2]]; + + const issues = []; + const current = MapPlainParseHelper.parsePrimitiveMap(entries, StringPlainParseHelper.parseString, NumberPlainParseHelper.parseNumber, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidMapEntryPlainParseIssue(new InvalidTypePlainParseIssue("string", "number"), undefined, "0") + ], "map")); + }); + + test("when invalid value", () => { + const entries: unknown = [["key_1", "invalid"], ["key_2", 2]]; + + const issues = []; + const current = MapPlainParseHelper.parsePrimitiveMap(entries, StringPlainParseHelper.parseString, NumberPlainParseHelper.parseNumber, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidMapEntryPlainParseIssue(undefined, new InvalidTypePlainParseIssue("number", "string"), "0") + ], "map")); + }); + }); + + + describe("parseHObjectMap", () => { + test("when valid", () => { + const entries: unknown = [["key_1", { primitiveField: 10 }], ["key_2", { primitiveField: 20 }]]; + + const current = MapPlainParseHelper.parseHObjectMap(entries, StringPlainParseHelper.parseString, OtherTestDTO); + + const expected = new Map([ + ["key_1", OtherTestDTO.cs({ primitiveField: 10 })], + ["key_2", OtherTestDTO.cs({ primitiveField: 20 })] + ]); + expect(current).toEqual(expected); + + }); + + test("when invalid type", () => { + const entries: unknown = 100; + + const issues = []; + const current = MapPlainParseHelper.parseHObjectMap(entries, StringPlainParseHelper.parseString, OtherTestDTO, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidTypePlainParseIssue("map_entries", "number", "map")); + }); + + test("when missing entry value", () => { + const entries: unknown = [["key_1"], ["key_2", { primitiveField: 20 }]]; + + const issues = []; + const current = MapPlainParseHelper.parseHObjectMap(entries, StringPlainParseHelper.parseString, OtherTestDTO, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidTypePlainParseIssue("map_entry", "array", "0") + ], "map")); + }); + + test("when missing entry key", () => { + const entries: unknown = [[{ primitiveField: 10 }], ["key_2", { primitiveField: 20 }]]; + + const issues = []; + const current = MapPlainParseHelper.parseHObjectMap(entries, StringPlainParseHelper.parseString, OtherTestDTO, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidTypePlainParseIssue("map_entry", "array", "0") + ], "map")); + }); + + test("when invalid key", () => { + const entries: unknown = [[123, { primitiveField: 10 }], ["key_2", { primitiveField: 20 }]]; + + const issues = []; + const current = MapPlainParseHelper.parseHObjectMap(entries, StringPlainParseHelper.parseString, OtherTestDTO, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidMapEntryPlainParseIssue(new InvalidTypePlainParseIssue("string", "number"), undefined, "0") + ], "map")); + }); + + test("when invalid value", () => { + const entries: unknown = [["key_1", { primitiveField: "invalid" }], ["key_2", { primitiveField: 20 }]]; + + const issues = []; + const current = MapPlainParseHelper.parseHObjectMap(entries, StringPlainParseHelper.parseString, OtherTestDTO, "map", issues); + + expect(issues).toEqual([current]); + expect(current).toEqual(new InvalidMapEntriesPlainParseIssue([ + new InvalidMapEntryPlainParseIssue(undefined, new InvalidHObjectPlainParseIssue(OtherTestDTO.HOBJ_META, [ + new InvalidTypePlainParseIssue("number", "string", "primitiveField") + ]), "0") + ], "map")); + }); + }); +});