Add applyPatch function to update models using objects returned by patch function.
All checks were successful
/ test (push) Successful in 56s

This commit is contained in:
Madeorsk 2025-06-22 17:25:12 +02:00
parent 7707789bbf
commit 2d86f0fa1a
Signed by: Madeorsk
GPG key ID: 677E51CA765BB79F
14 changed files with 425 additions and 1 deletions

View file

@ -398,6 +398,39 @@ export class Model<T extends object, Shape extends ModelShape<T>, Identifier ext
} }
return this.instance; return this.instance;
} }
/**
* Apply a patch to the model instance. All known fields will be deserialized and assigned to the properties.
* @param patch The patch object to apply.
* @param updateOriginals Indicates if the original properties values must be updated or not. By default, they are reset.
*/
applyPatch(patch: SerializedModel<T, Shape>, updateOriginals: boolean = true): ModelInstance<T, Shape, Identifier>
{
if (updateOriginals)
{ // If serialized original is null and we need to update it, initialize it.
this.original.serialized = this.serialize();
}
for (const serializedField in patch)
{ // For each field, if it's a property, assign its value.
// Get the property definition.
const property = this.definition.properties[serializedField as keyof Shape];
if (property)
{ // Found a matching model property, assigning its deserialized value.
(this.instance[serializedField as keyof Shape as keyof T] as any) =
(property as UnknownDefinition).type.applyPatch(this.instance[serializedField as keyof Shape as keyof T], patch[serializedField], updateOriginals);
if (updateOriginals)
{ // Update original values.
// Set original property value.
(this.original.properties[serializedField] as any) = (property as UnknownDefinition).type.clone(this.instance[serializedField as keyof Shape as keyof T]);
// Set original serialized value.
this.original.serialized[serializedField] = patch[serializedField];
}
}
}
return this.instance;
}
} }

View file

@ -113,6 +113,28 @@ export class ArrayType<SerializedValueType, SharkitekValueType> extends Type<Ser
return cloned; // Returning cloned array. return cloned; // Returning cloned array.
} }
applyPatch<T extends SharkitekValueType[]>(currentValue: T|null|undefined, patchValue: SerializedValueType[]|null|undefined, updateOriginals: boolean): T|null|undefined
{
if (patchValue === undefined) return undefined;
if (patchValue === null) return null;
if (!Array.isArray(patchValue))
throw new InvalidTypeValueError(this, patchValue, "value must be an array");
currentValue = Array.isArray(currentValue) ? currentValue : [] as T;
for (let i = 0; i < patchValue.length; i++)
{ // Apply the patch to all values of the array.
const patchedElement = this.valueDefinition.type.applyPatch(currentValue?.[i], patchValue[i], updateOriginals);
if (i < currentValue.length)
currentValue[i] = patchedElement;
else
currentValue.push(patchedElement);
}
return currentValue;
}
} }
/** /**

View file

@ -126,6 +126,17 @@ export class ModelType<T extends object, Shape extends ModelShape<T>, Identifier
return this.definedModel.model(value).clone() as Type; return this.definedModel.model(value).clone() as Type;
} }
applyPatch<Type extends ModelInstance<T, Shape, Identifier>>(currentValue: Type|null|undefined, patchValue: SerializedModel<T, Shape>|null|undefined, updateOriginals: boolean): Type|null|undefined
{
if (patchValue === undefined) return undefined;
if (patchValue === null) return null;
if (typeof patchValue !== "object" || Array.isArray(patchValue))
throw new InvalidTypeValueError(this, patchValue, "value must be an object");
return this.definedModel.model(currentValue).applyPatch(patchValue, updateOriginals) as Type;
}
} }
/** /**

View file

@ -1,5 +1,5 @@
import {Type} from "./type"; import {Type} from "./type";
import {define, Definition} from "../property-definition"; import {define, Definition, UnknownDefinition} from "../property-definition";
import {ModelProperties, ModelPropertiesValues, ModelProperty, ModelShape, SerializedModel} from "../model"; import {ModelProperties, ModelPropertiesValues, ModelProperty, ModelShape, SerializedModel} from "../model";
import {InvalidTypeValueError} from "../../errors"; import {InvalidTypeValueError} from "../../errors";
@ -176,6 +176,26 @@ export class ObjectType<Shape extends ModelShape<T>, T extends object> extends T
return cloned as Type; // Returning cloned object. return cloned as Type; // Returning cloned object.
} }
applyPatch<Type extends ModelPropertiesValues<T, Shape>>(currentValue: Type|null|undefined, patchValue: SerializedModel<T, Shape>|null|undefined, updateOriginals: boolean): Type|null|undefined
{
if (patchValue === undefined) return undefined;
if (patchValue === null) return null;
if (typeof patchValue !== "object" || Array.isArray(patchValue))
throw new InvalidTypeValueError(this, patchValue, "value must be an object");
const patchedValue: Partial<Type> = typeof currentValue === "object" && currentValue !== null ? currentValue : {};
for (const key in patchValue)
{ // Apply the patch to each property of the patch value.
const propertyDef = this.shape[key];
if (propertyDef)
patchedValue[key as keyof Type] = (propertyDef as UnknownDefinition).type.applyPatch(currentValue?.[key as keyof Type], patchValue[key], updateOriginals);
}
return patchedValue as Type;
}
} }
/** /**

View file

@ -61,4 +61,15 @@ export abstract class Type<SerializedType, ModelType>
{ {
return structuredClone(value); return structuredClone(value);
} }
/**
* Apply the patch value.
* @param currentValue The current property value. Its value can be mutated directly.
* @param patchValue The serialized patch value.
* @param updateOriginals Indicates if the original properties values must be updated or not.
*/
applyPatch<T extends ModelType>(currentValue: T|null|undefined, patchValue: SerializedType|null|undefined, updateOriginals: boolean): T|null|undefined
{
return this.deserialize(patchValue) as T;
}
} }

View file

@ -379,4 +379,45 @@ describe("model", () => {
expect((testArticle as any).unknownField).toBeUndefined(); expect((testArticle as any).unknownField).toBeUndefined();
expect((testArticle as any).anotherOne).toBeUndefined(); expect((testArticle as any).anotherOne).toBeUndefined();
}); });
it("applies patches to an existing model", () => {
const testArticle = Article.model.from({
id: 1,
title: "this is a test",
authors: [
Account.model.from({ id: 55, name: "John Doe", email: "test@test.test", createdAt: new Date(), active: true }),
],
text: "this is a long text",
evaluation: 8.52,
tags: [{ name: "test" }, { name: "foo" }],
unknownField: true,
anotherOne: "test",
});
Article.model.model(testArticle).resetDiff();
// Test simple patch.
Article.model.model(testArticle).applyPatch({
title: "new title",
});
expect(testArticle.title).toBe("new title");
expect(Article.model.model(testArticle).serializeDiff()).toStrictEqual({ id: 1 });
// Test originals update propagation.
Article.model.model(testArticle).applyPatch({
authors: [ { email: "john@test.test" } ]
});
expect(testArticle.authors[0].email).toBe("john@test.test");
expect(Article.model.model(testArticle).serializeDiff()).toStrictEqual({ id: 1 });
// Test without originals update.
Article.model.model(testArticle).applyPatch({
authors: [ { name: "Johnny" } ]
}, false);
expect(testArticle.authors[0].name).toBe("Johnny");
expect(Article.model.model(testArticle).serializeDiff()).toStrictEqual({
id: 1,
authors: [ { id: 55, name: "Johnny" } ]
});
});
}); });

View file

@ -121,6 +121,75 @@ describe("array type", () => {
} }
expect(testProperty.type.clone(undefined)).toBe(undefined); expect(testProperty.type.clone(undefined)).toBe(undefined);
expect(testProperty.type.clone(null)).toBe(null); expect(testProperty.type.clone(null)).toBe(null);
{ // Test simple patch.
expect(
testProperty.type.applyPatch([12.547, 8, -52.11], ["12.547", "444.34", "-52.11"], true)
).toEqual([12.547, 444.34, -52.11]);
expect(
testProperty.type.applyPatch(undefined, ["12.547", "444.34", "-52.11"], false)
).toEqual([12.547, 444.34, -52.11]);
expect(
testProperty.type.applyPatch(null, ["12.547", "444.34", "-52.11"], false)
).toEqual([12.547, 444.34, -52.11]);
expect(
testProperty.type.applyPatch([12.547, 8, -52.11], undefined, false)
).toBeUndefined();
expect(
testProperty.type.applyPatch([12.547, 8, -52.11], null, false)
).toBeNull();
}
{ // Invalid patch.
expect(
() => testProperty.type.applyPatch([12.547, 8, -52.11], {} as any, false)
).toThrow(InvalidTypeValueError);
}
{ // Test recursive patch.
const propertyValue = [
testModel.model(Object.assign(new TestModel(), { id: 1, name: "test", price: 22 })).instance,
testModel.model(Object.assign(new TestModel(), { id: 2, name: "another", price: 12.55 })).instance,
];
const patched = s.property.array(s.property.model(testModel)).type.applyPatch(propertyValue, [{
id: 1,
name: "new",
}, {
id: 2,
price: "13.65",
}], true);
// Check applied patch.
expect(patched).toEqual([
testModel.parse({ id: 1, name: "new", price: "22" }),
testModel.parse({ id: 2, name: "another", price: "13.65" }),
]);
// Check that originals have been updated.
expect(testModel.model(patched[0]).serializeDiff()).toEqual({ id: 1 });
patched[0].name = "test";
expect(testModel.model(patched[0]).serializeDiff()).toEqual({ id: 1, name: "test" });
expect(testModel.model(patched[1]).serializeDiff()).toEqual({ id: 2 });
patched[1].price = 12.55;
expect(testModel.model(patched[1]).serializeDiff()).toEqual({ id: 2, price: "12.55" });
}
{ // Test recursive patch without originals update.-
const propertyValue = [
testModel.model(Object.assign(new TestModel(), { id: 1, name: "test", price: 22 })).instance,
testModel.model(Object.assign(new TestModel(), { id: 2, name: "another", price: 12.55 })).instance,
];
const patched = s.property.array(s.property.model(testModel)).type.applyPatch(propertyValue, [{
id: 1,
name: "new",
}, {
id: 2,
price: "13.65",
}], false);
// Check that originals haven't been updated.
expect(testModel.model(patched[0]).serializeDiff()).toEqual({ id: 1, name: "new" });
expect(testModel.model(patched[1]).serializeDiff()).toEqual({ id: 2, price: "13.65" });
}
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {

View file

@ -49,6 +49,15 @@ describe("boolean type", () => {
s.property.boolean().type.resetDiff(false); s.property.boolean().type.resetDiff(false);
s.property.boolean().type.resetDiff(undefined); s.property.boolean().type.resetDiff(undefined);
s.property.boolean().type.resetDiff(null); s.property.boolean().type.resetDiff(null);
expect(s.property.boolean().type.applyPatch(false, true, true)).toBeTruthy();
expect(s.property.boolean().type.applyPatch(false, true, false)).toBeTruthy();
expect(s.property.boolean().type.applyPatch(true, false, false)).toBeFalsy();
expect(s.property.boolean().type.applyPatch(false, undefined, false)).toBeUndefined();
expect(s.property.boolean().type.applyPatch(false, null, false)).toBeNull();
expect(s.property.boolean().type.applyPatch(undefined, null, false)).toBeNull();
expect(s.property.boolean().type.applyPatch(null, null, false)).toBeNull();
expect(s.property.boolean().type.applyPatch(null, false, false)).toBeFalsy();
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {

View file

@ -58,6 +58,12 @@ describe("date type", () => {
expect(clonedPropertyValue).not.toBe(propertyValue); expect(clonedPropertyValue).not.toBe(propertyValue);
expect(clonedPropertyValue).toEqual(propertyValue); expect(clonedPropertyValue).toEqual(propertyValue);
} }
expect(s.property.date().type.applyPatch(new Date("2022-02-22"), testDate.toISOString(), false)?.getTime()).toBe(testDate.getTime());
expect(s.property.date().type.applyPatch(null, testDate.toISOString(), true)?.getTime()).toBe(testDate.getTime());
expect(s.property.date().type.applyPatch(undefined, "2565152-2156121-256123121 5121544175:21515612", false).valueOf()).toBeNaN();
expect(s.property.date().type.applyPatch(new Date(), undefined, false)).toBeUndefined();
expect(s.property.date().type.applyPatch(new Date(), null, false)).toBeNull();
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {

View file

@ -43,6 +43,12 @@ describe("decimal type", () => {
s.property.decimal().type.resetDiff(5.257); s.property.decimal().type.resetDiff(5.257);
s.property.decimal().type.resetDiff(undefined); s.property.decimal().type.resetDiff(undefined);
s.property.decimal().type.resetDiff(null); s.property.decimal().type.resetDiff(null);
expect(s.property.decimal().type.applyPatch(1, "5.257", false)).toBe(5.257);
expect(s.property.decimal().type.applyPatch(undefined, "5.257", true)).toBe(5.257);
expect(s.property.decimal().type.applyPatch(null, "5.257", false)).toBe(5.257);
expect(s.property.decimal().type.applyPatch(5.257, undefined, false)).toBeUndefined();
expect(s.property.decimal().type.applyPatch(5.257, null, false)).toBeNull();
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {

View file

@ -103,6 +103,132 @@ describe("model type", () => {
} }
expect(s.property.model(testModel).type.clone(undefined)).toBe(undefined); expect(s.property.model(testModel).type.clone(undefined)).toBe(undefined);
expect(s.property.model(testModel).type.clone(null)).toBe(null); expect(s.property.model(testModel).type.clone(null)).toBe(null);
{ // Apply a patch with undefined / NULL values.
expect(s.property.model(testModel).type.applyPatch(
testModel.model(Object.assign(new TestModel(), { id: 1, name: "test", price: 12.548777 })).instance,
undefined,
false
)).toBeUndefined();
expect(s.property.model(testModel).type.applyPatch(
testModel.model(Object.assign(new TestModel(), { id: 1, name: "test", price: 12.548777 })).instance,
null,
true
)).toBeNull();
}
{ // Invalid patch.
expect(
() => s.property.model(testModel).type.applyPatch(
testModel.model(Object.assign(new TestModel(), { id: 1, name: "test", price: 12.548777 })).instance,
5416 as any,
false
)
).toThrow(InvalidTypeValueError);
}
{ // Apply a patch with originals update.
{
const modelInstance = s.property.model(testModel).type.applyPatch(
testModel.model(Object.assign(new TestModel(), { id: 1, name: "test", price: 12.548777 })).instance,
{ id: 1, name: "another" },
true,
);
expect(testModel.model(modelInstance).getInstanceProperties()).toStrictEqual({
id: 1,
name: "another",
price: 12.548777,
});
expect(testModel.model(modelInstance).serializeDiff()).toStrictEqual({ id: 1 });
}
{
const modelInstance = s.property.model(testModel).type.applyPatch(
undefined,
{ id: 1, name: "test" },
true
);
expect(testModel.model(modelInstance).getInstanceProperties()).toStrictEqual({
id: 1,
name: "test",
price: undefined,
});
expect(testModel.model(modelInstance).serializeDiff()).toStrictEqual({ id: 1 });
}
{
const modelInstance = s.property.model(testModel).type.applyPatch(
null,
{ id: 1, name: "test" },
true
);
expect(testModel.model(modelInstance).getInstanceProperties()).toStrictEqual({
id: 1,
name: "test",
price: undefined,
});
expect(testModel.model(modelInstance).serializeDiff()).toStrictEqual({ id: 1 });
}
}
{ // Apply a patch without originals update.
{
const modelInstance = s.property.model(testModel).type.applyPatch(
testModel.model(Object.assign(new TestModel(), { id: 1, name: "test", price: 12.548777 })).instance,
{ id: 1, name: "another" },
false,
);
expect(testModel.model(modelInstance).getInstanceProperties()).toStrictEqual({
id: 1,
name: "another",
price: 12.548777,
});
expect(testModel.model(modelInstance).serializeDiff()).toStrictEqual({
id: 1,
name: "another",
});
}
{
const modelInstance = s.property.model(testModel).type.applyPatch(
undefined,
{ id: 1, name: "test" },
false
);
expect(testModel.model(modelInstance).getInstanceProperties()).toStrictEqual({
id: 1,
name: "test",
price: undefined,
});
expect(testModel.model(modelInstance).serializeDiff()).toStrictEqual({
id: 1,
name: "test",
});
}
{
const modelInstance = s.property.model(testModel).type.applyPatch(
null,
{ id: 1, name: "test" },
false
);
expect(testModel.model(modelInstance).getInstanceProperties()).toStrictEqual({
id: 1,
name: "test",
price: undefined,
});
expect(testModel.model(modelInstance).serializeDiff()).toStrictEqual({
id: 1,
name: "test",
});
}
}
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {

View file

@ -43,6 +43,12 @@ describe("numeric type", () => {
s.property.numeric().type.resetDiff(5.257); s.property.numeric().type.resetDiff(5.257);
s.property.numeric().type.resetDiff(undefined); s.property.numeric().type.resetDiff(undefined);
s.property.numeric().type.resetDiff(null); s.property.numeric().type.resetDiff(null);
expect(s.property.numeric().type.applyPatch(1, 5.257, false)).toBe(5.257);
expect(s.property.numeric().type.applyPatch(null, 5.257, true)).toBe(5.257);
expect(s.property.numeric().type.applyPatch(undefined, 5.257, false)).toBe(5.257);
expect(s.property.numeric().type.applyPatch(5.257, undefined, false)).toBeUndefined();
expect(s.property.numeric().type.applyPatch(5.257, null, false)).toBeNull();
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {

View file

@ -78,6 +78,64 @@ describe("object type", () => {
} }
expect(testProperty.type.clone(undefined)).toBe(undefined); expect(testProperty.type.clone(undefined)).toBe(undefined);
expect(testProperty.type.clone(null)).toBe(null); expect(testProperty.type.clone(null)).toBe(null);
{ // Apply a patch with undefined / NULL values.
expect(testProperty.type.applyPatch(
{ test: "test", another: 12.548777 },
undefined,
false
)).toBeUndefined();
expect(testProperty.type.applyPatch(
{ test: "test", another: 12.548777 },
null,
true
)).toBeNull();
}
{ // Invalid patch.
expect(
() => testProperty.type.applyPatch({ test: "test", another: 12.548777 }, 5416 as any, false)
).toThrow(InvalidTypeValueError);
}
{ // Apply a patch.
{
const objectInstance = testProperty.type.applyPatch(
{ test: "test", another: 12.548777 },
{ test: "another" },
true,
);
expect(objectInstance).toStrictEqual({
test: "another",
another: 12.548777,
});
}
{
const objectInstance = testProperty.type.applyPatch(
undefined,
{ test: "test" },
false
);
expect(objectInstance).toStrictEqual({
test: "test",
});
}
{
const objectInstance = testProperty.type.applyPatch(
null,
{ test: "test" },
false
);
expect(objectInstance).toStrictEqual({
test: "test",
});
}
}
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {

View file

@ -43,6 +43,12 @@ describe("string type", () => {
s.property.string().type.resetDiff("test"); s.property.string().type.resetDiff("test");
s.property.string().type.resetDiff(undefined); s.property.string().type.resetDiff(undefined);
s.property.string().type.resetDiff(null); s.property.string().type.resetDiff(null);
expect(s.property.string().type.applyPatch("another", "test", false)).toBe("test");
expect(s.property.string().type.applyPatch(undefined, "test", true)).toBe("test");
expect(s.property.string().type.applyPatch(null, "test", false)).toBe("test");
expect(s.property.string().type.applyPatch("test", undefined, false)).toBeUndefined();
expect(s.property.string().type.applyPatch("test", null, false)).toBeNull();
}); });
test("invalid parameters types", () => { test("invalid parameters types", () => {