2025-03-29 22:59:13 +01:00
|
|
|
import {Definition, UnknownDefinition} from "./property-definition";
|
|
|
|
import {ConstructorOf} from "../utils";
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A model shape.
|
|
|
|
*/
|
|
|
|
export type ModelShape<T extends object> = Partial<{
|
|
|
|
[k in keyof T]: Definition<unknown, T[k]>;
|
|
|
|
}>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Properties values of a model based on its shape.
|
|
|
|
*/
|
|
|
|
export type ModelPropertiesValues<T extends object, Shape extends ModelShape<T>> = {
|
|
|
|
[k in keyof Shape]: Shape[k]["_sharkitek"];
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Serialized object type based on model shape.
|
|
|
|
*/
|
|
|
|
export type SerializedModel<T extends object, Shape extends ModelShape<T>> = {
|
|
|
|
[k in keyof Shape]?: Shape[k]["_serialized"];
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* This is an experimental serialized model type declaration.
|
|
|
|
* @deprecated
|
|
|
|
*/
|
|
|
|
type ExperimentalSerializedModel<T extends object, Shape extends ModelShape<T>>
|
|
|
|
= Omit<ExperimentalSerializedModelBase<T, Shape>, ExperimentalSerializedModelOptionalKeys<T, Shape>>
|
|
|
|
& Pick<Partial<ExperimentalSerializedModelBase<T, Shape>>, ExperimentalSerializedModelOptionalKeys<T, Shape>>;
|
|
|
|
type ExperimentalSerializedModelBase<T extends object, Shape extends ModelShape<T>> = {
|
|
|
|
[k in keyof Shape]: Shape[k]["_serialized"];
|
|
|
|
};
|
|
|
|
type ExperimentalSerializedModelOptionalKeys<T extends object, Shape extends ModelShape<T>> = {
|
|
|
|
[k in keyof Shape]: Shape[k]["_serialized"] extends undefined ? k : never
|
|
|
|
}[keyof Shape];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A sharkitek model instance, with internal model state.
|
|
|
|
*/
|
|
|
|
export type ModelInstance<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>> = T & {
|
|
|
|
/**
|
|
|
|
* The Sharkitek model state.
|
|
|
|
*/
|
|
|
|
_sharkitek: Model<T, Shape, Identifier>;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Identifier definition type.
|
|
|
|
*/
|
|
|
|
export type IdentifierDefinition<T extends object, Shape extends ModelShape<T>> = (keyof Shape)|((keyof Shape)[]);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Identifier type.
|
|
|
|
*/
|
|
|
|
export type IdentifierType<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>>
|
|
|
|
= Identifier extends keyof Shape ? Shape[Identifier]["_sharkitek"] : { [K in keyof Identifier]: Identifier[K] extends keyof Shape ? Shape[Identifier[K]]["_sharkitek"] : unknown };
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A model definition object.
|
|
|
|
*/
|
|
|
|
export interface ModelDefinition<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>>
|
|
|
|
{
|
|
|
|
/**
|
|
|
|
* Model class.
|
|
|
|
*/
|
|
|
|
Class: ConstructorOf<T>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Properties names of the model identifier.
|
|
|
|
* Can be a single of a composite identifier.
|
|
|
|
*/
|
|
|
|
identifier?: Identifier;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Model properties definition.
|
|
|
|
* Set properties types (serialized and deserialized).
|
|
|
|
*/
|
|
|
|
properties: Shape;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A model property.
|
|
|
|
*/
|
|
|
|
export interface ModelProperty<T extends object, Shape extends ModelShape<T>>
|
|
|
|
{
|
|
|
|
/**
|
|
|
|
* Property name.
|
|
|
|
*/
|
|
|
|
name: keyof Shape;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Property definition.
|
|
|
|
*/
|
|
|
|
definition: UnknownDefinition;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Set if the property is part of the identifier or not.
|
|
|
|
*/
|
|
|
|
identifier: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Model properties iterator object.
|
|
|
|
*/
|
|
|
|
export type ModelProperties<T extends object, Shape extends ModelShape<T>> = ModelProperty<T, Shape>[];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A Sharkitek model state.
|
|
|
|
*/
|
|
|
|
export class Model<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>>
|
|
|
|
{
|
|
|
|
/**
|
|
|
|
* The model manager instance.
|
|
|
|
*/
|
|
|
|
readonly manager: ModelManager<T, Shape, Identifier>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The model definition object.
|
|
|
|
*/
|
|
|
|
readonly definition: ModelDefinition<T, Shape, Identifier>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Iterable properties.
|
|
|
|
*/
|
|
|
|
readonly properties: ModelProperties<T, Shape>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The actual instance of the model.
|
|
|
|
*/
|
|
|
|
instance: ModelInstance<T, Shape, Identifier>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Original values, to keep track of the changes on the model properties.
|
|
|
|
* @protected
|
|
|
|
*/
|
|
|
|
protected original: {
|
|
|
|
/**
|
|
|
|
* The original properties values.
|
|
|
|
*/
|
|
|
|
properties: Partial<ModelPropertiesValues<T, Shape>>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The original serialized object, if there is one.
|
|
|
|
*/
|
|
|
|
serialized: SerializedModel<T, Shape>|null;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize a new model state with the defined properties.
|
|
|
|
* @param manager The model manager.
|
|
|
|
*/
|
|
|
|
constructor(manager: ModelManager<T, Shape, Identifier>)
|
|
|
|
{
|
|
|
|
this.manager = manager;
|
|
|
|
this.definition = manager.definition;
|
|
|
|
this.properties = manager.properties;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize the Sharkitek model state for a new instance.
|
|
|
|
*/
|
|
|
|
initInstance(): this
|
|
|
|
{
|
|
|
|
return this.fromInstance(
|
|
|
|
// Initialize a new model instance.
|
|
|
|
new (this.definition.Class)()
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize the Sharkitek model state for the provided instance.
|
|
|
|
* @param instance The model instance.
|
|
|
|
*/
|
|
|
|
fromInstance(instance: T): this
|
|
|
|
{
|
|
|
|
// Initialize the sharkitek model instance.
|
|
|
|
const sharkitekInstance = instance as ModelInstance<T, Shape, Identifier>;
|
|
|
|
|
|
|
|
// Keep the original instance, if it exists.
|
|
|
|
const originalInstance = sharkitekInstance._sharkitek;
|
|
|
|
|
|
|
|
// Set references to instance / model state.
|
|
|
|
sharkitekInstance._sharkitek = this;
|
|
|
|
this.instance = sharkitekInstance;
|
|
|
|
|
|
|
|
if (originalInstance)
|
|
|
|
// Share the same original values object.
|
|
|
|
this.original = originalInstance.original;
|
|
|
|
else
|
|
|
|
{ // Initialize a new original values object, based on the current values of the instance.
|
|
|
|
this.original = {
|
|
|
|
properties: undefined,
|
|
|
|
serialized: null,
|
|
|
|
};
|
|
|
|
this.resetDiff();
|
|
|
|
}
|
|
|
|
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Deserialize provided data to a new model instance.
|
|
|
|
* @param serialized Serialized model.
|
|
|
|
*/
|
|
|
|
deserialize(serialized: SerializedModel<T, Shape>): this
|
|
|
|
{
|
|
|
|
// Initialize a new model instance.
|
|
|
|
this.initInstance();
|
|
|
|
|
|
|
|
for (const property of this.properties)
|
|
|
|
{ // For each defined model property, assigning its deserialized value.
|
|
|
|
(this.instance[property.name as keyof T] as any) = property.definition.type.deserialize(serialized[property.name]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Reset original property values.
|
|
|
|
this.resetDiff();
|
|
|
|
// Store the original serialized object.
|
|
|
|
this.original.serialized = serialized;
|
|
|
|
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get current model instance identifier.
|
|
|
|
*/
|
|
|
|
getIdentifier(): IdentifierType<T, Shape, Identifier>
|
|
|
|
{
|
|
|
|
if (Array.isArray(this.definition.identifier))
|
|
|
|
{ // The identifier is composite, make an array of properties values.
|
|
|
|
return this.definition.identifier.map(identifier => this.instance?.[identifier as keyof T]) as IdentifierType<T, Shape, Identifier>;
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{ // The identifier is a simple property, get its value.
|
|
|
|
return this.instance?.[this.definition.identifier as keyof Shape as keyof T] as IdentifierType<T, Shape, Identifier>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get current model instance properties.
|
|
|
|
*/
|
|
|
|
getInstanceProperties(): ModelPropertiesValues<T, Shape>
|
|
|
|
{
|
|
|
|
// Initialize an empty model properties object.
|
|
|
|
const instanceProperties: Partial<ModelPropertiesValues<T, Shape>> = {};
|
|
|
|
|
|
|
|
for (const property of this.properties)
|
|
|
|
{ // For each defined model property, adding it to the properties object.
|
|
|
|
instanceProperties[property.name] = this.instance?.[property.name as keyof T]; // keyof Shape is a subset of keyof T.
|
|
|
|
}
|
|
|
|
|
|
|
|
return instanceProperties as ModelPropertiesValues<T, Shape>; // Returning the properties object.
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Serialize the model instance.
|
|
|
|
*/
|
|
|
|
serialize(): SerializedModel<T, Shape>
|
|
|
|
{
|
|
|
|
// Creating an empty serialized object.
|
|
|
|
const serializedObject: Partial<SerializedModel<T, Shape>> = {};
|
|
|
|
|
|
|
|
for (const property of this.properties)
|
|
|
|
{ // For each defined model property, adding it to the serialized object.
|
|
|
|
serializedObject[property.name] = property.definition.type.serialize(
|
|
|
|
// keyof Shape is a subset of keyof T.
|
|
|
|
this.instance?.[property.name as keyof T],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return serializedObject as SerializedModel<T, Shape>; // Returning the serialized object.
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Examine if the model is new (never deserialized) or not.
|
|
|
|
*/
|
|
|
|
isNew(): boolean
|
|
|
|
{
|
|
|
|
return !this.original.serialized;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Examine if the model is dirty or not.
|
|
|
|
*/
|
|
|
|
isDirty(): boolean
|
|
|
|
{
|
|
|
|
for (const property of this.properties)
|
|
|
|
{ // For each property, check if it is different.
|
|
|
|
if (property.definition.type.hasChanged(this.original.properties?.[property.name], this.instance?.[property.name as keyof T]))
|
|
|
|
// There is a difference: the model is dirty.
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// No difference.
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Serialize the difference between current model state and the original one.
|
|
|
|
*/
|
|
|
|
serializeDiff(): Partial<SerializedModel<T, Shape>>
|
|
|
|
{
|
|
|
|
// Creating an empty serialized object.
|
|
|
|
const serializedObject: Partial<SerializedModel<T, Shape>> = {};
|
|
|
|
|
|
|
|
for (const property of this.properties)
|
|
|
|
{ // For each defined model property, adding it to the serialized object if it has changed or if it is in the identifier.
|
|
|
|
const instancePropValue = this.instance?.[property.name as keyof T]; // keyof Shape is a subset of keyof T.
|
|
|
|
if (
|
|
|
|
property.identifier ||
|
|
|
|
property.definition.type.hasChanged(this.original.properties?.[property.name], instancePropValue)
|
|
|
|
) // The property is part of the identifier or its value has changed.
|
|
|
|
serializedObject[property.name] = property.definition.type.serializeDiff(instancePropValue);
|
|
|
|
}
|
|
|
|
|
|
|
|
return serializedObject; // Returning the serialized object.
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Set current model properties as original values.
|
|
|
|
*/
|
|
|
|
resetDiff(): void
|
|
|
|
{
|
|
|
|
this.original.properties = {};
|
|
|
|
for (const property of this.properties)
|
|
|
|
{ // For each property, set its original value to the current property value.
|
|
|
|
const instancePropValue = this.instance?.[property.name as keyof T];
|
|
|
|
this.original.properties[property.name] = property.definition.type.clone(instancePropValue);
|
|
|
|
property.definition.type.resetDiff(instancePropValue);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get difference between original values and current ones, then reset it.
|
|
|
|
* Similar to call `serializeDiff()` then `resetDiff()`.
|
|
|
|
*/
|
|
|
|
patch(): Partial<SerializedModel<T, Shape>>
|
|
|
|
{
|
|
|
|
// Get the difference.
|
|
|
|
const diff = this.serializeDiff();
|
|
|
|
|
|
|
|
// Once the difference has been obtained, reset it.
|
|
|
|
this.resetDiff();
|
|
|
|
|
|
|
|
return diff; // Return the difference.
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Clone the model instance.
|
|
|
|
*/
|
|
|
|
clone(): ModelInstance<T, Shape, Identifier>
|
|
|
|
{
|
|
|
|
// Initialize a new instance for the clone.
|
|
|
|
const cloned = this.manager.model();
|
|
|
|
|
|
|
|
// Clone every value of the model instance.
|
|
|
|
for (const [key, value] of Object.entries(this.instance) as [keyof T, unknown][])
|
|
|
|
{ // For each [key, value], clone the value and put it in the cloned instance.
|
|
|
|
|
|
|
|
// Do not clone ourselves.
|
|
|
|
if (key == "_sharkitek") continue;
|
|
|
|
|
|
|
|
if (this.definition.properties[key])
|
|
|
|
{ // The current key is a defined property, clone using the defined type.
|
|
|
|
(cloned.instance[key] as any) = (this.definition.properties[key] as UnknownDefinition).type.clone(value);
|
|
|
|
}
|
|
|
|
else
|
|
|
|
{ // Not a property, cloning the raw value.
|
|
|
|
(cloned.instance[key] as any) = structuredClone(value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clone original properties.
|
|
|
|
for (const property of this.properties)
|
|
|
|
{ // For each property, clone its original value.
|
|
|
|
cloned.original.properties[property.name] = property.definition.type.clone(this.original.properties[property.name]);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clone original serialized.
|
|
|
|
cloned.original.serialized = structuredClone(this.original.serialized);
|
|
|
|
|
|
|
|
return cloned.instance; // Returning the cloned instance.
|
|
|
|
}
|
2025-04-20 20:30:27 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Assign the provided fields to existing properties.
|
|
|
|
* Fields that cannot be matched to existing properties are silently ignored.
|
|
|
|
* @param fields The fields to assign to the model.
|
|
|
|
*/
|
|
|
|
assign(fields: Partial<ModelPropertiesValues<T, Shape>> & {[field: string]: any}): ModelInstance<T, Shape, Identifier>
|
|
|
|
{
|
|
|
|
for (const field in fields)
|
|
|
|
{ // For each field, if it's a property, assign its value.
|
|
|
|
if ((this.definition.properties as any)?.[field])
|
|
|
|
// Set the instance value.
|
|
|
|
this.instance[field as keyof T] = fields[field];
|
|
|
|
}
|
|
|
|
return this.instance;
|
|
|
|
}
|
2025-03-29 22:59:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A model manager, created from a model definition.
|
|
|
|
*/
|
|
|
|
export class ModelManager<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>>
|
|
|
|
{
|
|
|
|
/**
|
|
|
|
* Defined properties.
|
|
|
|
*/
|
|
|
|
properties: ModelProperties<T, Shape>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize a model manager from a model definition.
|
|
|
|
* @param definition The model definition.
|
|
|
|
*/
|
|
|
|
constructor(public readonly definition: ModelDefinition<T, Shape, Identifier>)
|
|
|
|
{
|
|
|
|
this.initProperties();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize properties iterator from current definition.
|
|
|
|
* @protected
|
|
|
|
*/
|
|
|
|
protected initProperties(): void
|
|
|
|
{
|
|
|
|
// Build an array of model properties from the definition.
|
|
|
|
this.properties = [];
|
|
|
|
for (const propertyName in this.definition.properties)
|
|
|
|
{ // For each property, build a model property object.
|
|
|
|
this.properties.push({
|
|
|
|
name: propertyName,
|
|
|
|
definition: this.definition.properties[propertyName],
|
|
|
|
// Find out if the current property is part of the identifier.
|
|
|
|
identifier: (
|
|
|
|
Array.isArray(this.definition.identifier)
|
|
|
|
// The identifier is an array, the property must be in the array.
|
|
|
|
? this.definition.identifier.includes(propertyName as keyof Shape as keyof T)
|
|
|
|
// The identifier is a single string, the property must be the defined identifier.
|
|
|
|
: (this.definition.identifier == propertyName as keyof Shape)
|
|
|
|
),
|
|
|
|
} as ModelProperty<T, Shape>);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the model state of the provided model instance.
|
|
|
|
* @param instance The model instance for which to get its state. NULL or undefined to create a new one.
|
|
|
|
*/
|
|
|
|
model(instance: T|ModelInstance<T, Shape, Identifier>|null = null): Model<T, Shape, Identifier>
|
|
|
|
{ // Get the instance model state if there is one, or initialize a new one.
|
|
|
|
if (instance)
|
|
|
|
// There is an instance, create a model from it.
|
|
|
|
return ((instance as ModelInstance<T, Shape, Identifier>)?._sharkitek ?? (new Model<T, Shape, Identifier>(this))).fromInstance(instance);
|
|
|
|
else
|
|
|
|
// No instance, initialize a new one.
|
|
|
|
return (new Model<T, Shape, Identifier>(this)).initInstance();
|
|
|
|
}
|
|
|
|
|
2025-04-20 20:45:52 +02:00
|
|
|
/**
|
|
|
|
* Initialize a new model instance with the provided object properties values.
|
|
|
|
* Fields that cannot be matched to existing properties are silently ignored.
|
|
|
|
* @param fields
|
|
|
|
*/
|
|
|
|
from(fields: Partial<ModelPropertiesValues<T, Shape>> & {[field: string]: any}): ModelInstance<T, Shape, Identifier>
|
|
|
|
{
|
|
|
|
return this.model().assign(fields);
|
|
|
|
}
|
|
|
|
|
2025-03-29 22:59:13 +01:00
|
|
|
/**
|
|
|
|
* Parse the serialized model object to a new model instance.
|
|
|
|
* @param serialized The serialized model object.
|
|
|
|
*/
|
|
|
|
parse(serialized: SerializedModel<T, Shape>): ModelInstance<T, Shape, Identifier>
|
|
|
|
{
|
|
|
|
return this.model().deserialize(serialized).instance;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Define a new model.
|
|
|
|
* @param definition The model definition object.
|
|
|
|
*/
|
|
|
|
export function defineModel<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>>(
|
|
|
|
definition: ModelDefinition<T, Shape, Identifier>
|
|
|
|
)
|
|
|
|
{
|
|
|
|
return new ModelManager<T, Shape, Identifier>(definition);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A generic model manager for a provided model type, to use in circular dependencies.
|
|
|
|
*/
|
|
|
|
export type GenericModelManager<T extends object> = ModelManager<T, ModelShape<T>, IdentifierDefinition<T, ModelShape<T>>>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Function to get a model manager lazily.
|
|
|
|
*/
|
|
|
|
export type LazyModelManager<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>>
|
|
|
|
= (() => ModelManager<T, Shape, Identifier>);
|
|
|
|
/**
|
|
|
|
* A model manager definition that can be lazy.
|
|
|
|
*/
|
|
|
|
export type DeclaredModelManager<T extends object, Shape extends ModelShape<T>, Identifier extends IdentifierDefinition<T, Shape>>
|
|
|
|
= ModelManager<T, Shape, Identifier>|LazyModelManager<T, Shape, Identifier>;
|