diff --git a/README.md b/README.md index 8488517..5ac0e97 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ Sharkitek defines some basic types by default, in these classes: - `DateType`: date in the model, ISO formatted date in the serialized object. - `ArrayType`: array in the model, array in the serialized object. - `ObjectType`: object in the model, object in the serialized object. +- `MapType`: map in the model, record object in the serialized object. - `ModelType`: instance of a specific class in the model, object in the serialized object. When you are defining a property of a Sharkitek model, you must provide its type by instantiating one of these classes. @@ -246,6 +247,7 @@ To ease the use of these classes and reduce read complexity, properties of each - `DateType` => `s.property.date` - `ArrayType` => `s.property.array` - `ObjectType` => `s.property.object` +- `MapType` => `s.property.map` or `s.property.stringMap` - `ModelType` => `s.property.model` Type implementers should provide a corresponding function for each defined type. They can even provide multiple functions or constants with predefined parameters. For example, we could define `s.property.stringArray()` which would be similar to `s.property.array(s.property.string())`. diff --git a/src/model/properties.ts b/src/model/properties.ts index fe32339..d7bfd25 100644 --- a/src/model/properties.ts +++ b/src/model/properties.ts @@ -8,3 +8,5 @@ export {model} from "./types/model"; export {numeric} from "./types/numeric"; export {object} from "./types/object"; export {string} from "./types/string"; +export {map} from "./types/map"; +export {stringMap} from "./types/map"; diff --git a/src/model/types/map.ts b/src/model/types/map.ts new file mode 100644 index 0000000..147f739 --- /dev/null +++ b/src/model/types/map.ts @@ -0,0 +1,177 @@ +import {Type} from "./type"; +import {define, Definition} from "../property-definition"; +import {InvalidTypeValueError} from "../../errors"; +import {type} from "node:os"; +import {string} from "./string"; + +/** + * Type of a key-value map. + */ +export class MapType = Record> extends Type> +{ + /** + * Initialize a new map type of a Sharkitek model property. + * @param keyDefinition Definition of the map keys. + * @param valueDefinition Definition of the map values. + */ + constructor( + protected keyDefinition: Definition, + protected valueDefinition: Definition, + ) + { + super(); + } + + serialize(value: Map|null|undefined): SerializedMapType|null|undefined + { + if (value === undefined) return undefined; + if (value === null) return null; + + if (!(value instanceof Map)) throw new InvalidTypeValueError(this, value, "value must be an instance of map"); + + return Object.fromEntries( + // Serializing each key-value pair of the map. + value.entries().map(([key, value]) => + ([this.keyDefinition.type.serialize(key), this.valueDefinition.type.serialize(value)])) + ) as SerializedMapType; + } + + deserialize(value: SerializedMapType|null|undefined): Map|null|undefined + { + if (value === undefined) return undefined; + if (value === null) return null; + + if (typeof value !== "object" || Array.isArray(value)) throw new InvalidTypeValueError(this, value, "value must be an object"); + + const map = new Map; + for (const [serializedKey, serializedValue] of Object.entries(value)) + { // Deserializing each key-value pair of the map. + map.set(this.keyDefinition.type.deserialize(serializedKey), this.valueDefinition.type.deserialize(serializedValue)); + } + + return map; + } + + serializeDiff(value: Map|null|undefined): SerializedMapType|null|undefined + { + if (value === undefined) return undefined; + if (value === null) return null; + + if (!(value instanceof Map)) throw new InvalidTypeValueError(this, value, "value must be an instance of map"); + + return Object.fromEntries( + // Serializing the diff of each key-value pair of the map. + value.entries().map(([key, value]) => + ([this.keyDefinition.type.serializeDiff(key), this.valueDefinition.type.serializeDiff(value)])) + ) as SerializedMapType; + } + + resetDiff(value: Map|null|undefined): void + { + // Do nothing if it is not a map. + if (!(value instanceof Map)) return; + + // Reset diff of all key-value pairs. + value.forEach((value, key) => { + this.keyDefinition.type.resetDiff(key); + this.valueDefinition.type.resetDiff(value); + }); + } + + hasChanged(originalValue: Map|null|undefined, currentValue: Map|null|undefined): boolean + { + // If any map size is different, maps are different. + if (originalValue?.size != currentValue?.size) return true; + // If size is undefined, values are probably not maps. + if (originalValue?.size == undefined) return super.hasChanged(originalValue, currentValue); + + for (const [key, value] of originalValue.entries()) + { // Check for any change for each key-value in the map. + if (this.valueDefinition.type.hasChanged(value, currentValue.get(key))) + // The value has changed, the map is different. + return true; + } + + return false; // No change detected. + } + + serializedHasChanged(originalValue: SerializedMapType | null | undefined, currentValue: SerializedMapType | null | undefined): boolean + { + // If any value is not a defined object, use the default comparison function. + if (!originalValue || !currentValue || typeof originalValue !== "object" || typeof currentValue !== "object") return super.serializedHasChanged(originalValue, currentValue); + + // If any object size is different, objects are different. + if (Object.keys(originalValue)?.length != Object.keys(currentValue)?.length) return true; + + for (const [key, value] of Object.entries(originalValue)) + { // Check for any change for each key-value pair in the object. + if (this.valueDefinition.type.serializedHasChanged(value, currentValue[key])) + // The value has changed, the object is different. + return true; + } + + return false; // No change detected. + } + + clone>(map: T|null|undefined): T + { + // Handle NULL / undefined map. + if (!map) return super.clone(map); + + if (!(map instanceof Map)) throw new InvalidTypeValueError(this, map, "value must be an instance of map"); + + // Initialize an empty map. + const cloned = new Map() as T; + + for (const [key, value] of map.entries()) + { // Clone each value of the map. + cloned.set(this.keyDefinition.type.clone(key), this.valueDefinition.type.clone(value)); + } + + return cloned; // Returning cloned map. + } + + applyPatch>(currentValue: T|null|undefined, patchValue: SerializedMapType|null|undefined, updateOriginals: boolean): T|null|undefined + { + if (patchValue === undefined) return undefined; + if (patchValue === null) return null; + + if (typeof patchValue !== "object") + throw new InvalidTypeValueError(this, patchValue, "value must be an object"); + + currentValue = currentValue instanceof Map ? currentValue : new Map() as T; + + for (const [key, value] of Object.entries(patchValue)) + { // Apply the patch to all values of the map. + const patchedKey = this.keyDefinition.type.deserialize(key); + const patchedElement = this.valueDefinition.type.applyPatch(currentValue.get(patchedKey), value, updateOriginals); + currentValue.set(patchedKey, patchedElement); + } + + return currentValue; + } +} + +/** + * New map property definition. + * @param keyDefinition Definition of the map keys. + * @param valueDefinition Definition of the map values. + */ +export function map = Record>( + keyDefinition: Definition, + valueDefinition: Definition, +): Definition> +{ + return define(new MapType(keyDefinition, valueDefinition)); +} + +/** + * New map property definition, with string as index. + * @param valueDefinition Definition of the map values. + */ +export function stringMap = Record>( + valueDefinition: Definition, +): Definition> +{ + return define(new MapType(string(), valueDefinition)); +} diff --git a/tests/model/types/map.test.ts b/tests/model/types/map.test.ts new file mode 100644 index 0000000..0196963 --- /dev/null +++ b/tests/model/types/map.test.ts @@ -0,0 +1,162 @@ +import {describe, expect, test} from "vitest"; +import {InvalidTypeValueError, NumericType, s, StringType} from "../../../src/library"; +import {MapType} from "../../../src/model/types/map"; + +describe("map type", () => { + test("map type definition", () => { + const mapType = s.property.map(s.property.string(), s.property.numeric()); + expect(mapType.type).toBeInstanceOf(MapType); + }); + + const testProperty = s.property.map(s.property.string(), s.property.decimal()); + const testMapValue = new Map(); + testMapValue.set("test", 1.52); + testMapValue.set("another", 55); + + test("object type functions", () => { + expect(testProperty.type.serialize(testMapValue)).toEqual({ + test: "1.52", + another: "55", + }); + expect(testProperty.type.deserialize({ + test: "1.52", + another: "55", + })).toEqual(testMapValue); + expect(testProperty.type.serializeDiff(testMapValue)).toEqual({ + test: "1.52", + another: "55", + }); + + expect(testProperty.type.serialize(null)).toEqual(null); + expect(testProperty.type.deserialize(null)).toEqual(null); + expect(testProperty.type.serializeDiff(null)).toEqual(null); + + expect(testProperty.type.serialize(undefined)).toEqual(undefined); + expect(testProperty.type.deserialize(undefined)).toEqual(undefined); + expect(testProperty.type.serializeDiff(undefined)).toEqual(undefined); + + const anotherTestMapValue = new Map(); + anotherTestMapValue.set("test", 1.52); + anotherTestMapValue.set("another", 55); + expect(testProperty.type.hasChanged(testMapValue, anotherTestMapValue)).toBeFalsy(); + anotherTestMapValue.set("test", 1.521); + expect(testProperty.type.hasChanged(testMapValue, anotherTestMapValue)).toBeTruthy(); + anotherTestMapValue.delete("test"); + expect(testProperty.type.hasChanged(testMapValue, anotherTestMapValue)).toBeTruthy(); + expect(testProperty.type.hasChanged(null, null)).toBeFalsy(); + expect(testProperty.type.hasChanged(undefined, undefined)).toBeFalsy(); + expect(testProperty.type.hasChanged(null, undefined)).toBeTruthy(); + expect(testProperty.type.hasChanged(undefined, null)).toBeTruthy(); + expect(testProperty.type.hasChanged(null, testMapValue)).toBeTruthy(); + expect(testProperty.type.hasChanged(undefined, testMapValue)).toBeTruthy(); + expect(testProperty.type.hasChanged(testMapValue, null)).toBeTruthy(); + expect(testProperty.type.hasChanged(testMapValue, undefined)).toBeTruthy(); + + expect(testProperty.type.serializedHasChanged({ test: "1.52", another: "55" }, { test: "1.52", another: "55" })).toBeFalsy(); + expect(testProperty.type.serializedHasChanged({ test: "1.52", another: "55" }, { test: "1.521", another: "55" })).toBeTruthy(); + expect(testProperty.type.serializedHasChanged({ test: "1.52", another: "55" }, { another: "55" })).toBeTruthy(); + expect(testProperty.type.serializedHasChanged(null, null)).toBeFalsy(); + expect(testProperty.type.serializedHasChanged(undefined, undefined)).toBeFalsy(); + expect(testProperty.type.serializedHasChanged(null, undefined)).toBeTruthy(); + expect(testProperty.type.serializedHasChanged(undefined, null)).toBeTruthy(); + expect(testProperty.type.serializedHasChanged(null, { test: "1.52", another: "55" })).toBeTruthy(); + expect(testProperty.type.serializedHasChanged(undefined, { test: "1.52", another: "55" })).toBeTruthy(); + expect(testProperty.type.serializedHasChanged({ test: "1.52", another: "55" }, null)).toBeTruthy(); + expect(testProperty.type.serializedHasChanged({ test: "1.52", another: "55" }, undefined)).toBeTruthy(); + + testProperty.type.resetDiff(testMapValue); + testProperty.type.resetDiff(undefined); + testProperty.type.resetDiff(null); + + { // Test that keys and values are cloned in a different map. + const clonedTestMapValue = testProperty.type.clone(testMapValue); + expect(clonedTestMapValue).not.toBe(testMapValue); + expect(clonedTestMapValue).toEqual(testMapValue); + } + { // Test that values are cloned in a different object. + const propertyValue = new Map(); + propertyValue.set("test", [12, 11]); + const clonedPropertyValue = s.property.stringMap(s.property.array(s.property.numeric())).type.clone(propertyValue); + expect(clonedPropertyValue).not.toBe(propertyValue); + expect(clonedPropertyValue).toEqual(propertyValue); + expect(clonedPropertyValue.get("test")).not.toBe(propertyValue.get("test")); + expect(clonedPropertyValue.get("test")).toEqual(propertyValue.get("test")); + } + expect(testProperty.type.clone(undefined)).toBe(undefined); + expect(testProperty.type.clone(null)).toBe(null); + + { // Apply a patch with undefined / NULL values. + expect(testProperty.type.applyPatch( + testMapValue, + undefined, + false + )).toBeUndefined(); + expect(testProperty.type.applyPatch( + testMapValue, + null, + true + )).toBeNull(); + } + + { // Invalid patch. + expect( + () => testProperty.type.applyPatch(testMapValue, 5416 as any, false) + ).toThrow(InvalidTypeValueError); + } + + { // Apply a patch. + { + const objectInstance = testProperty.type.applyPatch( + testMapValue, + { test: "1.521" }, + true, + ); + + const expectedMapValue = new Map(); + expectedMapValue.set("test", 1.521); + expectedMapValue.set("another", 55); + expect(objectInstance).toStrictEqual(expectedMapValue); + } + + { + const objectInstance = testProperty.type.applyPatch( + undefined, + { test: "1.52" }, + false + ); + + const expectedMapValue = new Map(); + expectedMapValue.set("test", 1.52); + expect(objectInstance).toStrictEqual(expectedMapValue); + } + + { + const objectInstance = testProperty.type.applyPatch( + null, + { test: "1.52" }, + false + ); + + const expectedMapValue = new Map(); + expectedMapValue.set("test", 1.52); + expect(objectInstance).toStrictEqual(expectedMapValue); + } + } + }); + + test("invalid parameters types", () => { + expect(() => testProperty.type.serialize(5 as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.deserialize(5 as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.serializeDiff(5 as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.clone(5 as any)).toThrowError(InvalidTypeValueError); + + expect(() => testProperty.type.serialize([] as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.deserialize([] as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.serializeDiff([] as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.clone([] as any)).toThrowError(InvalidTypeValueError); + + expect(() => testProperty.type.serialize({} as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.serializeDiff({} as any)).toThrowError(InvalidTypeValueError); + expect(() => testProperty.type.clone({} as any)).toThrowError(InvalidTypeValueError); + }); +});