diff --git a/monosketch-svelte/src/lib/mono/shape/serialization/serializable.test.ts b/monosketch-svelte/src/lib/mono/shape/serialization/serializable.test.ts new file mode 100644 index 00000000..685af906 --- /dev/null +++ b/monosketch-svelte/src/lib/mono/shape/serialization/serializable.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2024, tuanchauict + */ + +import { Jsonizable, serializer, Serializer, SerialName } from "$mono/shape/serialization/serializable"; +import { describe, expect, it } from "vitest"; + +enum AnEnum { + A, + B, + C +} + +const AnEnumSerializer = { + serialize: (value: AnEnum): string => { + switch (value) { + case AnEnum.A: + return "xA"; + case AnEnum.B: + return "xB"; + case AnEnum.C: + return "xC"; + } + }, + + deserialize: (value: string): AnEnum => { + switch (value) { + case "xA": + return AnEnum.A; + case "xB": + return AnEnum.B; + case "xC": + return AnEnum.C; + default: + throw new Error(`Unrecognizable value ${value}`); + } + }, +} + +@Jsonizable +class Bar { + @SerialName("a") + public aString: string = ""; + + public aNumber: number = 123; + + private constructor() { + } + + static create(aString: string, aNumber: number): Bar { + const instance = new Bar(); + instance.aString = aString; + instance.aNumber = aNumber; + return instance; + } +} + +class Pair { + public first: number = 0; + public second: number = 0; + + constructor(first: number, second: number) { + this.first = first; + this.second = second; + } +} + +const PairSerializer = serializer( + (value: Pair) => `${value.first}|${value.second}`, + (value: string) => { + const [first, second] = value.split("|").map(Number); + if (isNaN(first) || isNaN(second)) { + throw new Error(`Invalid Pair format: ${value}`); + } + return new Pair(first, second); + } +) + +@Jsonizable +class Foo { + @SerialName("b") + public bar: Bar = Bar.create("default", 0); + + public aBoolean: boolean = true; + + @Serializer(AnEnumSerializer) + public anEnum: AnEnum = AnEnum.A; + + @SerialName("p") + @Serializer(PairSerializer) + public pair: Pair = new Pair(1, 2); + + private constructor() { + } + + static create(bar: Bar, aBoolean: boolean, anEnum: AnEnum): Foo { + const instance = new Foo(); + instance.bar = bar; + instance.aBoolean = aBoolean; + instance.anEnum = anEnum; + return instance; + } +} + +describe("Serializable", () => { + it("should serialize and deserialize correctly", () => { + const foo = Foo.create(Bar.create("foo-bar", 100), false, AnEnum.C); + // @ts-ignore + expect(foo.toJson()).toStrictEqual({ + b: { a: "foo-bar", aNumber: 100 }, + aBoolean: false, + anEnum: 'xC', + p: "1|2", + }); + + const json = { + b: { a: "a new string", aNumber: 999 }, + aBoolean: false, + anEnum: 'xB', + p: "3|4", + } + // @ts-ignore + const foo2 = Foo.fromJson(json); + expect(foo2.bar.aString).toBe("a new string"); + expect(foo2.bar.aNumber).toBe(999); + expect(foo2.aBoolean).toBe(false); + expect(foo2.anEnum).toBe(AnEnum.B); + expect(foo2.pair.first).toBe(3); + expect(foo2.pair.second).toBe(4); + }) +}) diff --git a/monosketch-svelte/src/lib/mono/shape/serialization/serializable.ts b/monosketch-svelte/src/lib/mono/shape/serialization/serializable.ts new file mode 100644 index 00000000..49e9daf5 --- /dev/null +++ b/monosketch-svelte/src/lib/mono/shape/serialization/serializable.ts @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2024, tuanchauict + */ + +/** + * An interface for custom serialization and deserialization. + */ +export interface Serializable { + serialize(value: any): any; + + deserialize(value: any): any; +} + +export const serializer = + (serialize: (value: any) => any, deserialize: (value: any) => any): Serializable => ({ serialize, deserialize }); + +/** + * A decorator to define a serial name for a field of a class. + * + * @param name - The serial name of the field. For example, if the field name is `foo` and the serial name is `f`, + * the field will be serialized as `{f: value}` instead of `{foo: value}`. + * @see Serializable + */ +export function SerialName(name: string) { + return function (target: any, propertyKey: string | symbol) { + if (!target.constructor.serialNames) { + target.constructor.serialNames = {}; + } + target.constructor.serialNames[propertyKey] = name; + }; +} + +/** + * A decorator to define a serializer for a field of a class. + * + * @param serializer - The serializer object. + * @see Serializable + */ +export function Serializer(serializer: Serializable) { + return function (target: any, propertyKey: string | symbol) { + if (!target.constructor.serializers) { + target.constructor.serializers = {}; + } + target.constructor.serializers[propertyKey] = serializer; + }; +} + +/** + * A decorator to make a class serializable with customizable field names and serializer functions. + * This decorator adds `toJson` and `fromJson` methods to the class, allowing instances to be + * serialized to JSON and deserialized from JSON. + * + * @param constructor - The constructor function of the class to be decorated. + */ +export function Jsonizable(constructor: Function) { + // @ts-ignore + if (!constructor.serializers) { + // @ts-ignore + constructor.serializers = {}; + } + // @ts-ignore + if (!constructor.serialNames) { + // @ts-ignore + constructor.serialNames = {}; + } + + // @ts-ignore + const serialNames = constructor.serialNames; + // @ts-ignore + const serializers = constructor.serializers; + + constructor.prototype.toJson = function () { + const json: any = {}; + const instance = this; + for (const key in instance) { + if (!instance.hasOwnProperty(key)) { + continue; + } + // If the key is not defined in serialNames, use the key itself + const serializedKey = serialNames[key] ?? key; + + // 1st: Check if the field has a serializer + // 2nd: Check if the field has a toJson method + // 3rd: Use the value directly + if (serializers[key]) { + json[serializedKey] = serializers[key].serialize(instance[key]); + } else if (instance[key].toJson) { + json[serializedKey] = instance[key].toJson(); + } else { + json[serializedKey] = instance[key]; + } + } + return json; + }; + + // @ts-ignore + constructor.fromJson = function (data: any) { + // @ts-ignore + const instance = new constructor(); + for (const key of Object.keys(instance)) { + // If the key is not defined in serialNames, use the key itself + const serializedKey = serialNames[key] ?? key; + const value = data[serializedKey]; + if (value === undefined) { + continue; + } + + const field = instance[key]; + // 1st: Check if the field has a serializer + // 2nd: Check if the field has a fromJson method + // 3rd: Use the value directly + if (serializers[key]) { + instance[key] = serializers[key].deserialize(value); + } else if (field.constructor && field.constructor.fromJson) { + instance[key] = field.constructor.fromJson(value); + } else { + instance[key] = value; + } + } + + return instance; + }; +} diff --git a/monosketch-svelte/tsconfig.json b/monosketch-svelte/tsconfig.json index 272ca4b4..47ac8d84 100644 --- a/monosketch-svelte/tsconfig.json +++ b/monosketch-svelte/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, + "experimentalDecorators": true, "module": "ESNext", "resolveJsonModule": true, /**