@lionrockjs/central
Version:
Node.js MVC framework inspire from PHP Kohana Framework
405 lines (338 loc) • 10.8 kB
JavaScript
/**
* Copyright (c) 2023 Kojin Nakana
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import ORM from './ORM.mjs';
import ORMAdapter from './adapter/ORM.mjs';
import ModelCollection from './ModelCollection.mjs';
export default class Model {
// ORM is abstract, joinTablePrefix and tableName is null.
static database = null;
static tableName = null;
// associative (junction) table name prefix
static joinTablePrefix = null;
static fields = new Map();
static belongsTo = new Map();
// hasMany cannot be Map, because children models may share same fk name.
static hasMany = [];
static belongsToMany = new Set();
static classPrefix = 'model/';
static defaultAdapter = ORMAdapter;
uuid = null;
created_at = null;
updated_at = null;
#database = null;
/** @type ORMOption */
#options = {};
#states = null;
#adapter = null;
#columns = null;
#defaultSelectColumns = null;
#collection = null;
/**
* @param {string | null} id
* @param {...ORMOption} options
* */
constructor(id = null, options = {}) {
this.#database = options.database || Model.database;
this.#options = options;
this.#states = [];
const Adapter = options.adapter || Model.defaultAdapter;
this.#adapter = new Adapter(this, this.#database);
// list all columns of the model.
this.#columns = Array.from(this.constructor.fields.keys());
// add belongsTo to columns
Array.from(this.constructor.belongsTo.keys()).forEach(x => this.#columns.push(x));
this.#defaultSelectColumns = ['id', 'created_at', 'updated_at', ...this.#columns];
this.id = id;
this.#collection = new ModelCollection(this.#adapter, this.#options, this.#defaultSelectColumns);
}
/**
*
* @returns {ModelCollection}
*/
getCollection(){
return this.#collection;
}
/**
* columns is a list of fields and belongsTo keys.
*
* @returns {Array}
*/
getColumns(){
return this.#columns;
}
/**
* states is a list of snapshots of the model.
*
* @returns {Array}
*/
getStates(){
return this.#states;
}
snapshot() {
this.#states.push({ ...this });
}
/**
*
* @param {object} option
* @param {String[]|*} option.with
* @param {...ORMOption} option
* @returns {Promise<void>}
*/
async eagerLoad(option = {}) {
/* options format, eg product
* {
* with:['Product'], //1. only with Classes will be loaded, 2. pass null to skip all classses and 3. undefined will load all classes
* default_image:{}
* type:{}
* vendor:{}
* variants:{
* with:['Inventory', 'Media],
* inventories :{}
* media: {}
* },
* media:{}
* tags:{}
* options:{}
* }
* */
//allow option with contain classes
const optWithClasses = new Map();
const optWith = (Array.isArray(option.with)) ? option.with.map(it => {
if(typeof it === 'string') return it;
optWithClasses.set(it.name, it);
return it.name;
}) : option.with;
const allowClasses = (optWith !== undefined) ? new Set(optWith) : null;
const parents = [];
this.constructor.belongsTo.forEach((v, k) => {
if (!allowClasses.has(v)) return;
const name = k.replace('_id', '');
parents.push({ name, opt: option[name], key: k });
});
await Promise.all(
parents.map(async p => {
const parentOptions = Object.assign({}, this.#options)
if(option.columns){
parentOptions.columns = option.columns;
}
if(option.database){
parentOptions.database = option.database;
}
const instance = await this.parent(p.key, parentOptions);
this[p.name] = instance;
if (!instance) return; // parent can be null
if(p.opt)await instance.eagerLoad(p.opt);
}),
);
const props = [];
await Promise.all(
this.constructor.hasMany.map(async x => {
const k = x[0];
const v = x[1];
if (!allowClasses.has(v)) return;
const ModelClass = optWithClasses.get(v) || await ORM.import(v);
const name = ModelClass.tableName;
props.push({
name, opt: option[name], key: k, model: ModelClass,
});
})
)
await Promise.all(
props.map(async p => {
const instances = await this.children(p.key, p.model);
if (!instances) return;
this[p.model.tableName] = instances;
if(p.opt){
await Promise.all(
instances.map(async instance => instance.eagerLoad(p.opt)),
);
}
}),
);
const siblings = [];
await Promise.all(
[...this.constructor.belongsToMany.keys()].map(async x => {
if (!allowClasses || !allowClasses.has(x)) return;
const ModelClass = optWithClasses.get(x) || await ORM.import(x);
const name = ModelClass.tableName;
siblings.push({ name, opt: option[name], model: ModelClass });
})
);
await Promise.all(
siblings.map(async s => {
const instances = await this.siblings(s.model);
if (!instances) return;
this[s.model.tableName] = instances;
if(s.opt){
await Promise.all(
instances.map(instance => instance.eagerLoad(s.opt)),
);
}
}),
);
}
/**
* get instance values which is not null
* @returns {Map<any, any>}
*/
#getValues() {
const values = new Map();
this.constructor.fields.forEach((v, k) => {
if (this[k])values.set(k, this[k]);
});
return values;
}
// instance methods
async writeRetry(data, retry=10, attempt=0){
if(attempt > retry)return;
try{
await this.#adapter.insert(data);
}catch(e){
console.log(e);
await this.writeRetry(data, retry, attempt + 1);
}
}
/**
* @return Model
*/
async write() {
if (this.id) {
await this.#adapter.update(this.#adapter.processValues());
} else {
const adapterClass = this.#adapter.constructor;
this.id = this.#options.insertID ?? adapterClass.defaultID() ?? ORMAdapter.defaultID();
this.uuid = adapterClass.uuid() ?? ORMAdapter.uuid();
this.created_at = Math.floor(Date.now() / 1000);
await this.writeRetry(this.#adapter.processValues(), this.#options.retry);
}
return this;
}
/**
*
* @returns {Promise<ORM>}
*/
async read(columns = this.#defaultSelectColumns) {
const result = await (
this.id
? this.#adapter.read(columns)
: this.#readByValues(columns)
);
if (!result) {
throw new Error(`Record not found. ${this.constructor.name} id:${this.id}`);
}
Object.assign(this, result);
}
async #readByValues(columns) {
const values = this.#getValues();
if (values.size === 0) throw new Error(`${this.constructor.name}: No id and no value to read`);
const results = await this.#adapter.readAll(values, columns, 1);
return results[0];
}
/**
*
* @returns {Promise<void>}
*/
async delete() {
if (!this.id) throw new Error('ORM delete Error, no id defined');
await this.#adapter.delete();
}
/**
*
* @param fk
* @param {...ORMOption} options
* @returns {Promise<*>}
*/
async parent(fk, options) {
// this fk is null or *, but not undefined
if (this[fk] === null) return null;
if (this[fk] === undefined) {
throw new Error(`${fk} is not foreign key in ${this.constructor.name}`);
}
const modelName = this.constructor.belongsTo.get(fk);
const ModelClass = await ORM.import(modelName);
return ORM.factory(ModelClass, this[fk], options);
}
/**
* has many
* @param {Model.constructor} MClass
* @param {string} fk
* @return {[]}
*/
async children(fk, MClass = null) {
const modelNames = this.constructor.hasMany.filter(value => (value[0] === fk));
if (modelNames.length > 1 && MClass === null) throw new Error('children fk have multiple Models, please specific which Model will be used');
const ModelClass = MClass || await ORM.import(modelNames[0][1]);
const results = await this.#adapter.hasMany(ModelClass.tableName, fk);
return results.map(x => Object.assign(new ModelClass(null, { database: this.#database }), x));
}
#siblingInfo(model) {
const m = Array.isArray(model) ? model[0] : model;
const M = m.constructor;
const lk = `${this.constructor.joinTablePrefix}_id`;
const fk = `${M.joinTablePrefix}_id`;
if (!this.constructor.belongsToMany.has(M.name)) {
if (!M.belongsToMany.has(this.constructor.name)) {
throw new Error(`${this.constructor.name} and ${M.name} not have many to many relationship`);
}
return {
joinTableName: `${M.joinTablePrefix}_${this.constructor.tableName}`,
lk,
fk,
};
}
return {
joinTableName: `${this.constructor.joinTablePrefix}_${M.tableName}`,
lk,
fk,
};
}
/**
* Get siblings
* @param {Model.} MClass
* @return {[]}
*/
async siblings(MClass) {
const { joinTableName, lk, fk } = this.#siblingInfo(ORM.create(MClass));
const results = await this.#adapter.belongsToMany(MClass.tableName, joinTableName, lk, fk);
return results.map(x => Object.assign(ORM.create(MClass, { database: this.#database }), x));
}
/**
* add belongsToMany
* @param {Model | Model[]} model
* @param {number} weight
* @returns void
*/
async add(model, weight = 0) {
if (!this.id) throw new Error(`Cannot add ${model.constructor.name}. ${this.constructor.name} not have id`);
// check model is not empty
if (!model) throw new Error('Error add model, model cannot be null or undefined');
if (Array.isArray(model) && model.length <= 0) throw new Error('Error add model, model array cannot be empty');
const { joinTableName, lk, fk } = this.#siblingInfo(model);
await this.#adapter.add(Array.isArray(model) ? model : [model], weight, joinTableName, lk, fk);
}
/**
* remove
* @param {Model| Model[]} model
*/
async remove(model) {
if (!this.id) throw new Error(`Cannot remove ${model.constructor.name}. ${this.constructor.name} not have id`);
const { joinTableName, lk, fk } = this.#siblingInfo(model);
await this.#adapter.remove(Array.isArray(model) ? model : [model], joinTableName, lk, fk);
}
/**
*
* @param MClass
* @returns {Promise<void>}
*/
async removeAll(MClass) {
if (!this.id) throw new Error(`Cannot remove ${MClass.name}. ${this.constructor.name} not have id`);
const { joinTableName, lk } = this.#siblingInfo(ORM.create(MClass));
await this.#adapter.removeAll(joinTableName, lk);
}
}
Object.freeze(Model.prototype);