Sharkitek base: model class and basic types dans behaviors.

This commit is contained in:
Madeorsk 2022-07-31 10:49:32 +02:00
parent 9d72c909cb
commit ff1d55da78
13 changed files with 706 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# IDEA
.idea/
*.iml
# JS library
coverage/
lib/
.parcel-cache/
.yarn/
.yarnrc*
yarn-error.log
.pnp*
yarn.lock

9
jest.config.js Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: [
"./tests",
],
};

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "core",
"version": "1.0.0",
"description": "Sharkitek core models library.",
"repository": "https://git.madeorsk.com/Sharkitek/core",
"author": "Madeorsk <madeorsk@protonmail.com>",
"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"
}

246
src/Model/Model.ts Normal file
View file

@ -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<any, any>;
}
/**
* 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<SerializedType, SharkitekType>(type: Type<SerializedType, SharkitekType>): PropertyDecorator
{
// Return the decorator function.
return (obj: ConstructorOf<Model>, 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<string, any> = {};
/**
* 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.
}
}

View file

@ -0,0 +1,53 @@
import {Type} from "./Type";
/**
* Type of an array of values.
*/
export class ArrayType<SerializedValueType, SharkitekValueType> extends Type<SerializedValueType[], SharkitekValueType[]>
{
/**
* Constructs a new array type of Sharkitek model property.
* @param valueType - Type of the array values.
*/
constructor(protected valueType: Type<SerializedValueType, SharkitekValueType>)
{
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<SerializedValueType, SharkitekValueType>(valueType: Type<SerializedValueType, SharkitekValueType>)
{
return new ArrayType<SerializedValueType, SharkitekValueType>(valueType);
}

View file

@ -0,0 +1,22 @@
import {Type} from "./Type";
/**
* Type of decimal numbers.
*/
export class DecimalType extends Type<string, number>
{
deserialize(value: string): number
{
return parseFloat(value);
}
serialize(value: number): string
{
return value.toString();
}
}
/**
* Type of decimal numbers;
*/
export const SDecimal = new DecimalType();

View file

@ -0,0 +1,55 @@
import {Type} from "./Type";
import {Model} from "../Model";
/**
* Type definition of the constructor of a specific type.
*/
export type ConstructorOf<T> = { new(): T; }
/**
* Type of a Sharkitek model value.
*/
export class ModelType<M extends Model> extends Type<any, M>
{
/**
* Constructs a new model type of a Sharkitek model property.
* @param modelConstructor - Constructor of the model.
*/
constructor(protected modelConstructor: ConstructorOf<M>)
{
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<M extends Model>(modelConstructor: ConstructorOf<M>)
{
return new ModelType(modelConstructor);
}

View file

@ -0,0 +1,22 @@
import {Type} from "./Type";
/**
* Type of any numeric value.
*/
export class NumericType extends Type<number, number>
{
deserialize(value: number): number
{
return value;
}
serialize(value: number): number
{
return value;
}
}
/**
* Type of any numeric value.
*/
export const SNumeric = new NumericType();

View file

@ -0,0 +1,22 @@
import {Type} from "./Type";
/**
* Type of any string value.
*/
export class StringType extends Type<string, string>
{
deserialize(value: string): string
{
return value;
}
serialize(value: string): string
{
return value;
}
}
/**
* Type of any string value.
*/
export const SString = new StringType();

55
src/Model/Types/Type.ts Normal file
View file

@ -0,0 +1,55 @@
/**
* Abstract class of a Sharkitek model property type.
*/
export abstract class Type<SerializedType, SharkitekType>
{
/**
* 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;
}
}

10
src/index.ts Normal file
View file

@ -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";

146
tests/Model.test.ts Normal file
View file

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

21
tsconfig.json Normal file
View file

@ -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"
]
}
}