From 75b7b35dd6c70da4a88c5d828846ca8f0ae1acf0 Mon Sep 17 00:00:00 2001
From: Madeorsk <m@deor.sk>
Date: Sun, 22 Jun 2025 19:26:33 +0200
Subject: [PATCH] Add map type and document it.

---
 README.md                     |   2 +
 src/model/properties.ts       |   2 +
 src/model/types/map.ts        | 177 ++++++++++++++++++++++++++++++++++
 tests/model/types/map.test.ts | 162 +++++++++++++++++++++++++++++++
 4 files changed, 343 insertions(+)
 create mode 100644 src/model/types/map.ts
 create mode 100644 tests/model/types/map.test.ts

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<KeyType, ValueType, SerializedValueType, SerializedMapType extends Record<string, SerializedValueType> = Record<string, SerializedValueType>> extends Type<SerializedMapType, Map<KeyType, ValueType>>
+{
+	/**
+	 * 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<string, KeyType>,
+		protected valueDefinition: Definition<SerializedValueType, ValueType>,
+	)
+	{
+		super();
+	}
+
+	serialize(value: Map<KeyType, ValueType>|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<KeyType, ValueType>|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<KeyType, ValueType>;
+		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<KeyType, ValueType>|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<KeyType, ValueType>|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<KeyType, ValueType>|null|undefined, currentValue: Map<KeyType, ValueType>|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<T extends Map<KeyType, ValueType>>(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<KeyType, ValueType>() 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<T extends Map<KeyType, ValueType>>(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<KeyType, ValueType>() 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<KeyType, ValueType, SerializedValueType, SerializedMapType extends Record<string, SerializedValueType> = Record<string, SerializedValueType>>(
+	keyDefinition: Definition<string, KeyType>,
+	valueDefinition: Definition<SerializedValueType, ValueType>,
+): Definition<SerializedMapType, Map<KeyType, ValueType>>
+{
+	return define(new MapType<KeyType, ValueType, SerializedValueType, SerializedMapType>(keyDefinition, valueDefinition));
+}
+
+/**
+ * New map property definition, with string as index.
+ * @param valueDefinition Definition of the map values.
+ */
+export function stringMap<ValueType, SerializedValueType, SerializedMapType extends Record<string, SerializedValueType> = Record<string, SerializedValueType>>(
+	valueDefinition: Definition<SerializedValueType, ValueType>,
+): Definition<SerializedMapType, Map<string, ValueType>>
+{
+	return define(new MapType<string, ValueType, SerializedValueType, SerializedMapType>(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<string, number>();
+	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<string, number>();
+		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<string, number>();
+				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<string, number>();
+				expectedMapValue.set("test", 1.52);
+				expect(objectInstance).toStrictEqual(expectedMapValue);
+			}
+
+			{
+				const objectInstance = testProperty.type.applyPatch(
+					null,
+					{ test: "1.52" },
+					false
+				);
+
+				const expectedMapValue = new Map<string, number>();
+				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);
+	});
+});