diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2e47b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# IDEA + +.idea/ +*.iml + +# JS library + +coverage/ +lib/ +.parcel-cache/ +.yarn/ +.yarnrc* +yarn-error.log +.pnp* + +yarn.lock diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c66b70d --- /dev/null +++ b/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + + roots: [ + "./tests", + ], +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..07ba880 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "core", + "version": "1.0.0", + "description": "Sharkitek core models library.", + "repository": "https://git.madeorsk.com/Sharkitek/core", + "author": "Madeorsk ", + "license": "MIT", + "scripts": { + "build": "parcel build", + "dev": "parcel watch", + "test": "jest" + }, + "main": "lib/index.js", + "source": "src/index.ts", + "types": "lib/index.d.ts", + "dependencies": { + "reflect-metadata": "^0.1.13" + }, + "devDependencies": { + "@parcel/packager-ts": "2.6.2", + "@parcel/transformer-typescript-types": "2.6.2", + "@types/jest": "^28.1.6", + "jest": "^28.1.3", + "parcel": "^2.6.2", + "ts-jest": "^28.0.7", + "typescript": "^4.7.4" + }, + "packageManager": "yarn@3.2.2" +} diff --git a/src/Model/Model.ts b/src/Model/Model.ts new file mode 100644 index 0000000..d7caf3d --- /dev/null +++ b/src/Model/Model.ts @@ -0,0 +1,246 @@ +import {Type} from "./Types/Type"; +import "reflect-metadata"; +import {ConstructorOf} from "./Types/ModelType"; + +/** + * Key of Sharkitek property metadata. + */ +const sharkitekMetadataKey = Symbol("sharkitek"); + +/** + * Key of Sharkitek model identifier. + */ +const modelIdentifierMetadataKey = Symbol("modelIdentifier"); + +/** + * Sharkitek property metadata interface. + */ +interface SharkitekMetadataInterface +{ + /** + * Property type instance. + */ + type: Type; +} + +/** + * Property decorator to define a Sharkitek model identifier. + */ +export function Identifier(obj: Model, propertyName: string): void +{ + // Register the current property as identifier of the current model object. + Reflect.defineMetadata(modelIdentifierMetadataKey, propertyName, obj); +} + +/** + * Property decorator for Sharkitek models properties. + * @param type - Type of the property. + */ +export function Property(type: Type): PropertyDecorator +{ + // Return the decorator function. + return (obj: ConstructorOf, propertyName) => { + // Initializing property metadata. + const metadata: SharkitekMetadataInterface = { + type: type, + }; + // Set property metadata. + Reflect.defineMetadata(sharkitekMetadataKey, metadata, obj, propertyName); + }; +} + +/** + * A Sharkitek model. + */ +export abstract class Model +{ + /** + * Get the Sharkitek model identifier. + * @private + */ + private getModelIdentifier(): string + { + return Reflect.getMetadata(modelIdentifierMetadataKey, this); + } + /** + * Get the Sharkitek metadata of the property. + * @param propertyName - The name of the property for which to get metadata. + * @private + */ + private getPropertyMetadata(propertyName: string): SharkitekMetadataInterface + { + return Reflect.getMetadata(sharkitekMetadataKey, this, propertyName); + } + /** + * Calling a function for a defined property. + * @param propertyName - The property for which to check definition. + * @param callback - The function called when the property is defined. + * @param notProperty - The function called when the property is not defined. + * @protected + */ + protected propertyWithMetadata(propertyName: string, callback: (propertyMetadata: SharkitekMetadataInterface) => void, notProperty: () => void = () => {}): unknown + { + // Getting the current property metadata. + const propertyMetadata = this.getPropertyMetadata(propertyName); + if (propertyMetadata) + // Metadata are defined, calling the right callback. + return callback(propertyMetadata); + else + // Metadata are not defined, calling the right callback. + return notProperty(); + } + /** + * Calling a function for each defined property. + * @param callback - The function to call. + * @protected + */ + protected forEachModelProperty(callback: (propertyName: string, propertyMetadata: SharkitekMetadataInterface) => unknown): any|void + { + for (const propertyName of Object.keys(this)) + { // For each property, checking that its type is defined and calling the callback with its type. + const result = this.propertyWithMetadata(propertyName, (propertyMetadata) => { + // If the property is defined, calling the function with the property name and metadata. + const result = callback(propertyName, propertyMetadata); + + // If there is a return value, returning it directly (loop is broken). + if (typeof result !== "undefined") return result; + + // Update metadata if they have changed. + Reflect.defineMetadata(sharkitekMetadataKey, propertyMetadata, this, propertyName); + }); + + // If there is a return value, returning it directly (loop is broken). + if (typeof result !== "undefined") return result; + } + } + + + /** + * The original properties values. + * @protected + */ + protected _originalProperties: Record = {}; + + /** + * The original (serialized) object. + * @protected + */ + protected _originalObject: any = null; + + /** + * Determine if the model is new or not. + */ + isNew(): boolean + { + return !this._originalObject; + } + + /** + * Determine if the model is dirty or not. + */ + isDirty(): boolean + { + return this.forEachModelProperty((propertyName, propertyMetadata) => ( + // For each property, checking if it is different. + propertyMetadata.type.propertyHasChanged(this._originalProperties[propertyName], (this as any)[propertyName]) + // There is a difference, we should return false. + ? true + // There is not difference, returning nothing. + : undefined + )) === true; + } + + /** + * Get model identifier. + */ + getIdentifier(): unknown + { + return (this as any)[this.getModelIdentifier()]; + } + + /** + * Set current properties values as original values. + */ + resetDiff() + { + this.forEachModelProperty((propertyName, propertyMetadata) => { + // For each property, set its original value to its current property value. + this._originalProperties[propertyName] = (this as any)[propertyName]; + propertyMetadata.type.resetDiff((this as any)[propertyName]); + }); + } + /** + * Serialize the difference between current model state and original one. + */ + serializeDiff(): any + { + // Creating a serialized object. + const serializedDiff: any = {}; + + this.forEachModelProperty((propertyName, propertyMetadata) => { + // For each defined model property, adding it to the serialized object if it has changed. + if (this.getModelIdentifier() == propertyName + || propertyMetadata.type.propertyHasChanged(this._originalProperties[propertyName], (this as any)[propertyName])) + // Adding the current property to the serialized object if it is the identifier or its value has changed. + serializedDiff[propertyName] = propertyMetadata.type.serializeDiff((this as any)[propertyName]); + }) + + return serializedDiff; // Returning the serialized object. + } + /** + * Get difference between original values and current ones, then reset it. + * Similar to call `serializeDiff()` then `resetDiff()`. + */ + save(): any + { + // Get the difference. + const diff = this.serializeDiff(); + + // Once the difference has been gotten, reset it. + this.resetDiff(); + + return diff; // Return the difference. + } + + + /** + * Serialize the model. + */ + serialize(): void + { + // Creating a serialized object. + const serializedObject: any = {}; + + this.forEachModelProperty((propertyName, propertyMetadata) => { + // For each defined model property, adding it to the serialized object. + serializedObject[propertyName] = propertyMetadata.type.serialize((this as any)[propertyName]); + }); + + return serializedObject; // Returning the serialized object. + } + + /** + * Special operations on parse. + * @protected + */ + protected parse(): void + {} // Nothing by default. TODO: create a event system to create functions like "beforeDeserialization" or "afterDeserialization". + + /** + * Deserialize the model. + */ + deserialize(serializedObject: any): this + { + this.forEachModelProperty((propertyName, propertyMetadata) => { + // For each defined model property, assigning its deserialized value to the model. + (this as any)[propertyName] = propertyMetadata.type.deserialize(serializedObject[propertyName]); + }); + + // Reset original property values. + this.resetDiff(); + + this._originalObject = serializedObject; // The model is not a new one, but loaded from a deserialized one. + + return this; // Returning this, after deserialization. + } +} diff --git a/src/Model/Types/ArrayType.ts b/src/Model/Types/ArrayType.ts new file mode 100644 index 0000000..85ec8ac --- /dev/null +++ b/src/Model/Types/ArrayType.ts @@ -0,0 +1,53 @@ +import {Type} from "./Type"; + +/** + * Type of an array of values. + */ +export class ArrayType extends Type +{ + /** + * Constructs a new array type of Sharkitek model property. + * @param valueType - Type of the array values. + */ + constructor(protected valueType: Type) + { + super(); + } + + serialize(value: SharkitekValueType[]): SerializedValueType[] + { + return value.map((value) => ( + // Serializing each value of the array. + this.valueType.serialize(value) + )); + } + + deserialize(value: SerializedValueType[]): SharkitekValueType[] + { + return value.map((serializedValue) => ( + // Deserializing each value of the array. + this.valueType.deserialize(serializedValue) + )); + } + + serializeDiff(value: SharkitekValueType[]): any + { + // Serializing diff of all elements. + return value.map((value) => this.valueType.serializeDiff(value)); + } + + resetDiff(value: SharkitekValueType[]): void + { + // Reset diff of all elements. + value.forEach((value) => this.valueType.resetDiff(value)); + } +} + +/** + * Type of an array of values. + * @param valueType - Type of the array values. + */ +export function SArray(valueType: Type) +{ + return new ArrayType(valueType); +} diff --git a/src/Model/Types/DecimalType.ts b/src/Model/Types/DecimalType.ts new file mode 100644 index 0000000..7caa2d8 --- /dev/null +++ b/src/Model/Types/DecimalType.ts @@ -0,0 +1,22 @@ +import {Type} from "./Type"; + +/** + * Type of decimal numbers. + */ +export class DecimalType extends Type +{ + deserialize(value: string): number + { + return parseFloat(value); + } + + serialize(value: number): string + { + return value.toString(); + } +} + +/** + * Type of decimal numbers; + */ +export const SDecimal = new DecimalType(); diff --git a/src/Model/Types/ModelType.ts b/src/Model/Types/ModelType.ts new file mode 100644 index 0000000..6a2049f --- /dev/null +++ b/src/Model/Types/ModelType.ts @@ -0,0 +1,55 @@ +import {Type} from "./Type"; +import {Model} from "../Model"; + +/** + * Type definition of the constructor of a specific type. + */ +export type ConstructorOf = { new(): T; } + +/** + * Type of a Sharkitek model value. + */ +export class ModelType extends Type +{ + /** + * Constructs a new model type of a Sharkitek model property. + * @param modelConstructor - Constructor of the model. + */ + constructor(protected modelConstructor: ConstructorOf) + { + super(); + } + + serialize(value: M): any + { + // Serializing the given model. + return value.serialize(); + } + + deserialize(value: any): M + { + // Deserializing the given object in the new model. + return (new this.modelConstructor()).deserialize(value); + } + + serializeDiff(value: M): any + { + // Serializing the given model. + return value.serializeDiff(); + } + + resetDiff(value: M): void + { + // Reset diff of the given model. + value.resetDiff(); + } +} + +/** + * Type of a Sharkitek model value. + * @param modelConstructor - Constructor of the model. + */ +export function SModel(modelConstructor: ConstructorOf) +{ + return new ModelType(modelConstructor); +} diff --git a/src/Model/Types/NumericType.ts b/src/Model/Types/NumericType.ts new file mode 100644 index 0000000..e4c249f --- /dev/null +++ b/src/Model/Types/NumericType.ts @@ -0,0 +1,22 @@ +import {Type} from "./Type"; + +/** + * Type of any numeric value. + */ +export class NumericType extends Type +{ + deserialize(value: number): number + { + return value; + } + + serialize(value: number): number + { + return value; + } +} + +/** + * Type of any numeric value. + */ +export const SNumeric = new NumericType(); diff --git a/src/Model/Types/StringType.ts b/src/Model/Types/StringType.ts new file mode 100644 index 0000000..9133ca4 --- /dev/null +++ b/src/Model/Types/StringType.ts @@ -0,0 +1,22 @@ +import {Type} from "./Type"; + +/** + * Type of any string value. + */ +export class StringType extends Type +{ + deserialize(value: string): string + { + return value; + } + + serialize(value: string): string + { + return value; + } +} + +/** + * Type of any string value. + */ +export const SString = new StringType(); diff --git a/src/Model/Types/Type.ts b/src/Model/Types/Type.ts new file mode 100644 index 0000000..8c983f6 --- /dev/null +++ b/src/Model/Types/Type.ts @@ -0,0 +1,55 @@ +/** + * Abstract class of a Sharkitek model property type. + */ +export abstract class Type +{ + /** + * Serialize the given value of a Sharkitek model property. + * @param value - Value to serialize. + */ + abstract serialize(value: SharkitekType): SerializedType; + + /** + * Deserialize the given value of a serialized Sharkitek model. + * @param value - Value to deserialize. + */ + abstract deserialize(value: SerializedType): SharkitekType; + + /** + * Serialize the given value only if it has changed. + * @param value - Value to deserialize. + */ + serializeDiff(value: SharkitekType): SerializedType|null + { + return this.serialize(value); // By default, nothing changes. + } + + /** + * Reset the difference between the original value and the current one. + * @param value - Value for which reset diff data. + */ + resetDiff(value: SharkitekType): void + { + // By default, nothing to do. + } + + /** + * Determine if the property value has changed. + * @param originalValue - Original property value. + * @param currentValue - Current property value. + */ + propertyHasChanged(originalValue: SharkitekType, currentValue: SharkitekType): boolean + { + return originalValue != currentValue; + } + + /** + * Determine if the serialized property value has changed. + * @param originalValue - Original serialized property value. + * @param currentValue - Current serialized property value. + */ + serializedPropertyHasChanged(originalValue: SerializedType, currentValue: SerializedType): boolean + { + return originalValue != currentValue; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4bed17c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,10 @@ + + +export * from "./Model/Model"; + +export * from "./Model/Types/Type"; +export * from "./Model/Types/ArrayType"; +export * from "./Model/Types/DecimalType"; +export * from "./Model/Types/ModelType"; +export * from "./Model/Types/NumericType"; +export * from "./Model/Types/StringType"; diff --git a/tests/Model.test.ts b/tests/Model.test.ts new file mode 100644 index 0000000..2e608d1 --- /dev/null +++ b/tests/Model.test.ts @@ -0,0 +1,146 @@ +import {SArray, SDecimal, SModel, SNumeric, SString, Identifier, Model, Property} from "../src"; + +/** + * Another test model. + */ +class Author extends Model +{ + @Property(SString) + name: string = undefined; + + @Property(SString) + firstName: string = undefined; + + @Property(SString) + email: string = undefined; + + constructor(name: string = undefined, firstName: string = undefined, email: string = undefined) + { + super(); + + this.name = name; + this.firstName = firstName; + this.email = email; + } +} + +/** + * A test model. + */ +class Article extends Model +{ + @Property(SNumeric) + @Identifier + id: number = undefined; + + @Property(SString) + title: string = undefined; + + @Property(SArray(SModel(Author))) + authors: Author[] = []; + + @Property(SString) + text: string = undefined; + + @Property(SDecimal) + evaluation: number = undefined; +} + +it("deserialize", () => { + expect((new Article()).deserialize({ + id: 1, + title: "this is a test", + authors: [ + { name: "DOE", firstName: "John", email: "test@test.test" }, + { name: "TEST", firstName: "Another", email: "another@test.test" }, + ], + text: "this is a long test.", + evaluation: "25.23", + }).serialize()).toStrictEqual({ + id: 1, + title: "this is a test", + authors: [ + { name: "DOE", firstName: "John", email: "test@test.test" }, + { name: "TEST", firstName: "Another", email: "another@test.test" }, + ], + text: "this is a long test.", + evaluation: "25.23", + }); +}); + +it("create and check state then serialize", () => { + const article = new Article(); + article.id = 1; + article.title = "this is a test"; + article.authors = [ + new Author("DOE", "John", "test@test.test"), + ]; + article.text = "this is a long test."; + article.evaluation = 25.23; + + expect(article.isNew()).toBeTruthy(); + expect(article.getIdentifier()).toStrictEqual(1); + + expect(article.serialize()).toStrictEqual({ + id: 1, + title: "this is a test", + authors: [ + { name: "DOE", firstName: "John", email: "test@test.test" }, + ], + text: "this is a long test.", + evaluation: "25.23", + }); +}); + + +it("deserialize then save", () => { + const article = (new Article()).deserialize({ + id: 1, + title: "this is a test", + authors: [ + { name: "DOE", firstName: "John", email: "test@test.test" }, + { name: "TEST", firstName: "Another", email: "another@test.test" }, + ], + text: "this is a long test.", + evaluation: "25.23", + }); + + expect(article.isNew()).toBeFalsy(); + expect(article.isDirty()).toBeFalsy(); + expect(article.evaluation).toStrictEqual(25.23); + + article.text = "Modified text."; + + expect(article.isDirty()).toBeTruthy(); + + expect(article.save()).toStrictEqual({ + id: 1, + text: "Modified text.", + }); +}); + +it("save with modified submodels", () => { + const article = (new Article()).deserialize({ + id: 1, + title: "this is a test", + authors: [ + { name: "DOE", firstName: "John", email: "test@test.test" }, + { name: "TEST", firstName: "Another", email: "another@test.test" }, + ], + text: "this is a long test.", + evaluation: "25.23", + }); + + article.authors = article.authors.map((author) => { + author.name = "TEST"; + return author; + }); + + expect(article.save()).toStrictEqual({ + id: 1, + authors: [ + { name: "TEST", }, + {}, //{ name: "TEST", firstName: "Another", email: "another@test.test" }, + ], + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7ee2891 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "files": ["src/index.ts"], + "compilerOptions": { + "outDir": "./lib/", + "noImplicitAny": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "ES6", + "moduleResolution": "Node", + "target": "ES5", + + "lib": [ + "ESNext", + "DOM" + ] + } +}