diff --git a/src/model/model.ts b/src/model/model.ts index b253973..b3a7910 100644 --- a/src/model/model.ts +++ b/src/model/model.ts @@ -398,6 +398,39 @@ export class Model, Identifier ext } 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, updateOriginals: boolean = true): ModelInstance + { + 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; + } } diff --git a/src/model/types/array.ts b/src/model/types/array.ts index 46b5a92..fee8198 100644 --- a/src/model/types/array.ts +++ b/src/model/types/array.ts @@ -113,6 +113,28 @@ export class ArrayType extends Type(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; + } } /** diff --git a/src/model/types/model.ts b/src/model/types/model.ts index 216463d..d6fe05d 100644 --- a/src/model/types/model.ts +++ b/src/model/types/model.ts @@ -126,6 +126,17 @@ export class ModelType, Identifier return this.definedModel.model(value).clone() as Type; } + + applyPatch>(currentValue: Type|null|undefined, patchValue: SerializedModel|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; + } } /** diff --git a/src/model/types/object.ts b/src/model/types/object.ts index cf912e9..e171796 100644 --- a/src/model/types/object.ts +++ b/src/model/types/object.ts @@ -1,5 +1,5 @@ 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 {InvalidTypeValueError} from "../../errors"; @@ -176,6 +176,26 @@ export class ObjectType, T extends object> extends T return cloned as Type; // Returning cloned object. } + + applyPatch>(currentValue: Type|null|undefined, patchValue: SerializedModel|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 = 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; + } } /** diff --git a/src/model/types/type.ts b/src/model/types/type.ts index 6c636df..a477daa 100644 --- a/src/model/types/type.ts +++ b/src/model/types/type.ts @@ -61,4 +61,15 @@ export abstract class Type { 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(currentValue: T|null|undefined, patchValue: SerializedType|null|undefined, updateOriginals: boolean): T|null|undefined + { + return this.deserialize(patchValue) as T; + } } diff --git a/tests/model.test.ts b/tests/model.test.ts index bc5416c..35eb505 100644 --- a/tests/model.test.ts +++ b/tests/model.test.ts @@ -379,4 +379,45 @@ describe("model", () => { expect((testArticle as any).unknownField).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" } ] + }); + }); }); diff --git a/tests/model/types/array.test.ts b/tests/model/types/array.test.ts index 9becff2..75cedd4 100644 --- a/tests/model/types/array.test.ts +++ b/tests/model/types/array.test.ts @@ -121,6 +121,75 @@ describe("array type", () => { } expect(testProperty.type.clone(undefined)).toBe(undefined); 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", () => { diff --git a/tests/model/types/boolean.test.ts b/tests/model/types/boolean.test.ts index 1b683dc..e77e6ab 100644 --- a/tests/model/types/boolean.test.ts +++ b/tests/model/types/boolean.test.ts @@ -49,6 +49,15 @@ describe("boolean type", () => { s.property.boolean().type.resetDiff(false); s.property.boolean().type.resetDiff(undefined); 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", () => { diff --git a/tests/model/types/date.test.ts b/tests/model/types/date.test.ts index 8a59097..9cac39a 100644 --- a/tests/model/types/date.test.ts +++ b/tests/model/types/date.test.ts @@ -58,6 +58,12 @@ describe("date type", () => { expect(clonedPropertyValue).not.toBe(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", () => { diff --git a/tests/model/types/decimal.test.ts b/tests/model/types/decimal.test.ts index cfe9ce6..6aeec3d 100644 --- a/tests/model/types/decimal.test.ts +++ b/tests/model/types/decimal.test.ts @@ -43,6 +43,12 @@ describe("decimal type", () => { s.property.decimal().type.resetDiff(5.257); s.property.decimal().type.resetDiff(undefined); 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", () => { diff --git a/tests/model/types/model.test.ts b/tests/model/types/model.test.ts index 6633822..f4b6ef7 100644 --- a/tests/model/types/model.test.ts +++ b/tests/model/types/model.test.ts @@ -103,6 +103,132 @@ describe("model type", () => { } expect(s.property.model(testModel).type.clone(undefined)).toBe(undefined); 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", () => { diff --git a/tests/model/types/numeric.test.ts b/tests/model/types/numeric.test.ts index 49dbc17..5344ad1 100644 --- a/tests/model/types/numeric.test.ts +++ b/tests/model/types/numeric.test.ts @@ -43,6 +43,12 @@ describe("numeric type", () => { s.property.numeric().type.resetDiff(5.257); s.property.numeric().type.resetDiff(undefined); 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", () => { diff --git a/tests/model/types/object.test.ts b/tests/model/types/object.test.ts index d721265..12609c3 100644 --- a/tests/model/types/object.test.ts +++ b/tests/model/types/object.test.ts @@ -78,6 +78,64 @@ describe("object type", () => { } 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( + { 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", () => { diff --git a/tests/model/types/string.test.ts b/tests/model/types/string.test.ts index fd95396..252036b 100644 --- a/tests/model/types/string.test.ts +++ b/tests/model/types/string.test.ts @@ -43,6 +43,12 @@ describe("string type", () => { s.property.string().type.resetDiff("test"); s.property.string().type.resetDiff(undefined); 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", () => {