Add map type and document it.
All checks were successful
/ test (push) Successful in 26s

This commit is contained in:
Madeorsk 2025-06-22 19:26:33 +02:00
parent 2d86f0fa1a
commit 75b7b35dd6
Signed by: Madeorsk
GPG key ID: 677E51CA765BB79F
4 changed files with 343 additions and 0 deletions

View file

@ -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. - `DateType`: date in the model, ISO formatted date in the serialized object.
- `ArrayType`: array in the model, array in the serialized object. - `ArrayType`: array in the model, array in the serialized object.
- `ObjectType`: object in the model, object 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. - `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. 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` - `DateType` => `s.property.date`
- `ArrayType` => `s.property.array` - `ArrayType` => `s.property.array`
- `ObjectType` => `s.property.object` - `ObjectType` => `s.property.object`
- `MapType` => `s.property.map` or `s.property.stringMap`
- `ModelType` => `s.property.model` - `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())`. 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())`.

View file

@ -8,3 +8,5 @@ export {model} from "./types/model";
export {numeric} from "./types/numeric"; export {numeric} from "./types/numeric";
export {object} from "./types/object"; export {object} from "./types/object";
export {string} from "./types/string"; export {string} from "./types/string";
export {map} from "./types/map";
export {stringMap} from "./types/map";

177
src/model/types/map.ts Normal file
View file

@ -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));
}

View file

@ -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);
});
});