const EventEmitter = require('node:events');
const path = require('node:path');
const { _, isPlainObject, isEmpty } = require('@kitmi/utils');
const { generateDisplayName, deepCloneField, Clonable, entityNaming, uniqNamespace } = require('./XemlUtils');
const Types = require('./Types');
const Field = require('./Field');
/**
* Entity event listener
* @callback Entity.eventListener
* returns {*}
*/
/**
* Geml entity
* @class Entity
*/
class Entity extends Clonable {
static overrideEntityMeta(sourceInfo, overrideInfo) {
if (overrideInfo.features) {
sourceInfo.features = [...(sourceInfo.features ?? []), ...overrideInfo.features];
}
if (overrideInfo.fields) {
sourceInfo.fields = {
...sourceInfo.fields,
...overrideInfo.fields,
};
}
if (overrideInfo.associations) {
sourceInfo.associations = [...(sourceInfo.associations ?? []), ...overrideInfo.associations];
}
if (overrideInfo.indexes) {
const newIndexes = [];
const removeIndexes = [];
overrideInfo.indexes.forEach((index) => {
if (index.overrideRemove) {
removeIndexes.push(_.castArray(index.fields));
} else {
newIndexes.push(index);
}
});
const keepIndexes = sourceInfo.indexes
? sourceInfo.indexes.filter((index) => {
const fields = _.castArray(index.fields);
return !removeIndexes.find(
(removeFields) =>
fields.length === removeFields.length &&
fields.every((field) => removeFields.includes(field))
);
})
: [];
sourceInfo.indexes = [...keepIndexes, ...newIndexes];
}
if (overrideInfo.inputs) {
sourceInfo.inputs = {
...sourceInfo.inputs,
...overrideInfo.inputs,
};
}
if (overrideInfo.data) {
sourceInfo.data = [...(sourceInfo.data ?? []), ...overrideInfo.data];
}
if (overrideInfo.views) {
sourceInfo.views = {
...sourceInfo.views,
...overrideInfo.views,
};
}
if (overrideInfo.xemlModule) {
sourceInfo.namespace = overrideInfo.xemlModule.namespace;
}
}
/**
* Fields of the entity, map of <fieldName, fieldObject>
* @member {object.<string, Field>}
*/
fields = {};
/**
* Referenced types
*/
types = {};
/**
* @param {Linker} linker
* @param {string} name
* @param {*} xemlModule
* @param {object} info
*/
constructor(linker, name, xemlModule, info) {
super();
this._events = new EventEmitter();
/**
* Linker to process this entity
* @member {Linker}
*/
this.linker = linker;
/**
* Name of this entity
* @member {string}
*/
this.name = entityNaming(name);
/**
* Owner geml module
* @member {object}
*/
this.xemlModule = info.namespace
? { ...xemlModule, namespace: uniqNamespace([...(xemlModule.namespace ?? []), ...info.namespace]) }
: xemlModule;
/**
* Raw metadata
* @member {Object}
*/
this.info = info;
}
/**
* Listen on an event
* @param {string} eventName
* @param {Entity.eventListener} listener
* @returns {EventEmitter}
*/
once(eventName, listener) {
return this._events.once(eventName, listener);
}
/**
* Start linking this entity
* @returns {Entity}
*/
link() {
if (this.linked) {
throw new Error('Entity already linked');
}
//1.inherit from base entity if any
//2.initialize features
//3.add fields
//4.api
//indexes will processed after processing foreign relationship
this.linker.log('debug', 'Linking entity [' + this.name + '] ...');
if (this.info.code) {
this.code = this.info.code || this.name;
}
if (this.info.base) {
//inherit fields, processed features, key and indexes
let baseClasses = _.castArray(this.info.base);
baseClasses.reverse().forEach((base) => {
let baseEntity;
if (isPlainObject(base)) {
baseEntity = this.linker.loadEntityTemplate(this.xemlModule, base.name, base.args);
} else {
baseEntity = this.linker.loadEntity(this.xemlModule, base);
}
if (!baseEntity.linked) {
throw new Error(`Entity [${baseEntity.name}] is not linked when is inherited.`);
}
this._inherit(baseEntity);
});
this.baseClasses = baseClasses;
}
if (this.info.comment) {
/**
* @member {string}
*/
this.comment = this.info.comment;
}
/**
* @member {string}
*/
this.displayName = generateDisplayName(this.name);
/**
* @fires Entity#featuresMixingIn
*/
this._events.emit('featuresMixingIn');
// load features
if (this.info.features) {
this.info.features.forEach((feature) => {
let featureName;
if (typeof feature === 'string') {
featureName = feature;
} else {
featureName = feature.name;
}
let fn;
try {
fn = require(path.resolve(__dirname, `./entityFeatures/${featureName}.js`));
} catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
throw new Error(`Unknown feature "${featureName}" reference in entity "${this.name}"`);
}
}
fn(this, this.linker.translateXemlValue(this.xemlModule, feature.args));
});
}
/**
* @fires Entity#beforeAddingFields
*/
this._events.emit('beforeAddingFields');
// process fields
if (this.info.fields) {
_.each(this.info.fields, (fieldInfo, fieldName) => this.addField(fieldName, fieldInfo));
}
/**
* @fires Entity#afterAddingFields
*/
this._events.emit('afterAddingFields');
if (this.info.key) {
this.key = this.info.key;
if (Array.isArray(this.key) && this.key.length === 1) {
this.key = this.key[0];
}
}
if (this.info.inputs) {
this.inputs = this.info.inputs;
}
if (this.info.views) {
this.views = this.info.views;
}
if (this.info.modifiers) {
this.modifiers = this.info.modifiers;
}
this.linked = true;
return this;
}
addUsedType(type, typeLocation) {
const existing = this.types[type];
if (existing == null) {
this.types[type] = typeLocation;
} else {
if (existing !== typeLocation) {
//should never happen
throw new Error('Different used types appear in the same entity!');
}
}
}
/**
* Check whether the entity has an index on the given fields
* @param {array} fields
* @returns {boolean}
*/
hasIndexOn(fields) {
fields = fields.concat();
fields.sort();
return (
_.findIndex(this.indexes, (index) => {
return _.findIndex(index.fields, (f, idx) => fields.length <= idx || fields[idx] !== f) === -1;
}) != -1
);
}
/**
* Add all indexes
*/
addIndexes() {
if (this.info.indexes) {
_.each(this.info.indexes, (index) => {
this.addIndex(index, true);
});
}
}
/**
* Add an index
* @param {object} index
* @property {array} index.fields - Fields of the index
* @property {bool} index.unique - Flag of uniqueness of the index
* @returns {Entity}
*/
addIndex(index, skipExisting) {
if (!this.indexes) {
this.indexes = [];
}
index = _.cloneDeep(index);
if (!index.fields) {
throw new Error('Index fields is required.');
}
if (!_.isArray(index.fields)) {
index.fields = [index.fields];
}
let fields = index.fields;
index.fields = _.map(fields, (field) => {
let normalizedField = field; //_.camelCase(field);
if (!this.hasField(normalizedField)) {
throw new Error(`Index references non-exist field: ${field}, entity: ${this.name}.`);
}
return normalizedField;
});
index.fields.sort();
if (this.hasIndexOn(index.fields)) {
if (skipExisting) {
return this;
}
throw new Error(`Index on [${index.fields.join(', ')}] already exist in entity [${this.name}].`);
}
this.indexes.push(index);
return this;
}
/**
* Get a field object by field name or entity meta accesor (e.g. $key, $feature).
* @param fieldId
* @returns {Field}
*/
getEntityAttribute(fieldId) {
if (fieldId[0] === '$') {
let token = fieldId.substr(1);
switch (token) {
case 'key':
if (Array.isArray(this.key)) {
throw new Error('Combination key not support for accesor "$key".');
}
return this.fields[this.key];
case 'feature':
return this.features;
default:
throw new Error(`Filed accessor "${token}" not supported!`);
}
} else {
if (!this.hasField(fieldId)) {
throw new Error(`Field "${fieldId}" not exists in entity "${this.name}".`);
}
return this.fields[fieldId];
}
}
/**
* Check whether the entity has a field with given name
* @param {string} name
* @returns {boolean}
*/
hasField(name) {
if (Array.isArray(name)) {
return _.every(name, (fn) => this.hasField(fn));
}
return name in this.fields;
}
/**
* Add association, dbms-specific
* @param {*} name
* @param {*} props
* @example
* e.g. mysql
* entity - Associated entity name
* join - Join type, e.g. INNER, LEFT, RIGHT, OUTER
* exclude - Exclude in output columns
* alias - Alias
* on - On conditions
* dataset - Sub query
* assocs - Child associations
* optional - Optional
* 'default' - Default value
* list - Is a list
*/
addAssociation(name, props) {
if (!this.associations) {
this.associations = {};
}
if (name in this.associations) {
throw new Error(
`Association "${name}" already exists in entity "${this.name}". Props: ` + JSON.stringify(props)
);
}
this.associations[name] = props;
}
/**
* Add a association field.
* @param {string} name
* @param {Entity} destEntity
* @param {Field} destField
*/
addAssocField(name, destEntity, destField, extraProps) {
let localField = this.fields[name];
if (localField) {
throw new Error(`Field "${name}" already exists in entity "${this.name}".`);
}
let destFieldInfo = _.omit(destField.toJSON(), Types.ASSOC_EXCLUDE_QUALIFIERS);
Object.assign(destFieldInfo, extraProps);
this.addField(name, destFieldInfo);
//this.fields[name].displayName = fieldNaming(prefixNaming(destEntity.name, destField.name));
}
/**
* Add a field into the entity
* @param {string} name
* @param {object} rawInfo
* @returns {Entity}
*/
addField(name, rawInfo) {
if (this.hasField(name)) {
throw new Error(`Field name [${name}] conflicts in entity [${this.name}].`);
}
if (!rawInfo.type) {
throw new Error(`Missing "type" for field "${name}".`);
}
let field;
if (rawInfo instanceof Field) {
field = rawInfo.clone();
field.name = name; // todo: displayName
} else {
let [fullRawInfo, baseInfo] = this.linker.trackBackType(this.xemlModule, rawInfo);
if (baseInfo != null) {
this.addUsedType(rawInfo.type, baseInfo.xemlModule.id);
}
field = new Field(name, fullRawInfo);
field.link();
}
this.fields[name] = field;
if (!this.key) {
//make the first field as the default key
this.key = name;
}
return this;
}
/**
* Add a feature into the entity, e.g. auto increment id
* @param {string} name
* @param {*} feature
* @param {bool} [allowMultiple=false] - Allow multiple occurrence
* @returns {Entity}
*/
addFeature(name, feature, allowMultiple) {
if (!this.features) {
this.features = {};
}
if (allowMultiple) {
if (!this.features[name]) {
this.features[name] = [];
}
this.features[name].push(feature);
} else {
this.features[name] = feature;
}
return this;
}
hasFeature(name) {
return this.features && name in this.features;
}
/**
* Set key name
* @param {string|array.<string>} name - Field name to be used as the key
* @returns {Entity}
*/
setKey(name) {
this.key = name;
return this;
}
getReferencedEntityByPath(dotPath) {
let parts = dotPath.split('.');
let entity = this;
for (let i = 0, l = parts.length; i < l; i++) {
let part = parts[i];
entity = entity.getReferencedEntity(part);
if (entity == null) {
throw new Error(`Entity association "${part}" not found in entity "${this.name}".`);
}
}
return entity;
}
getReferencedEntity(entityName) {
return this.linker.loadEntity(this.xemlModule, entityName);
}
/**
* Returns the association info if there is connection to the given destination entity.
*/
getReferenceTo(entityName, includes, excludes) {
return (
this.info.associations &&
_.find(this.info.associations, (assoc) => {
if (includes) {
if (
_.find(includes, (value, prop) =>
typeof value === 'function' ? !value(assoc[prop]) : !_.isEqual(assoc[prop], value)
)
)
return false;
}
if (excludes) {
if (excludes.association && assoc === excludes.association) return false;
if (excludes.type && assoc.type === excludes.type) return false;
if (excludes.associations && excludes.associations.indexOf(assoc) > -1) return false;
if (excludes.types && excludes.types.indexOf(assoc.type) > -1) return false;
if (excludes.props && _.find(excludes.props, (prop) => assoc[prop])) return false;
}
return assoc.destEntity === entityName;
})
);
}
/**
* Get key field
* @returns {*}
*/
getKeyField() {
return Array.isArray(this.key) ? this.key.map((kf) => this.fields[kf]) : this.fields[this.key];
}
/**
* Clone the entity
* @param {Map} [stack] - Reference stack to avoid recurrence copy
* @returns {Entity}
*/
clone() {
super.clone();
let entity = new Entity(this.linker, this.name, this.xemlModule, this.info);
deepCloneField(this, entity, 'code');
deepCloneField(this, entity, 'baseClasses');
deepCloneField(this, entity, 'displayName');
deepCloneField(this, entity, 'comment');
deepCloneField(this, entity, 'features');
deepCloneField(this, entity, 'modifiers');
deepCloneField(this, entity, 'fields');
deepCloneField(this, entity, 'types');
deepCloneField(this, entity, 'associations');
deepCloneField(this, entity, 'key');
deepCloneField(this, entity, 'indexes');
deepCloneField(this, entity, 'inputs');
deepCloneField(this, entity, 'views');
entity.linked = true;
return entity;
}
/**
* Translate the entity into a plain JSON object
* @returns {object}
*/
toJSON() {
return {
name: this.name,
code: this.code,
displayName: this.displayName,
comment: this.comment,
...(this.baseClasses ? { baseClasses: this.baseClasses } : {}),
features: this.features,
modifiers: this.modifiers,
types: this.types,
fields: _.mapValues(this.fields, (field) => field.toJSON()),
associations: this.associations,
key: this.key,
indexes: this.indexes,
};
}
_getFeatureNameFromInfo(featureItem) {
if (typeof featureItem === 'string') {
return featureItem;
}
return featureItem.name;
}
_inherit(baseEntity) {
let overrideInfo = {};
if (baseEntity.baseClasses) {
let baseClasses = baseEntity.baseClasses;
if (this.baseClasses) {
this.baseClasses = _.uniq(baseClasses.concat(this.baseClasses));
} else {
this.baseClasses = baseClasses.concat();
}
}
if (!isEmpty(baseEntity.info.features)) {
let baseFeatures = _.cloneDeep(baseEntity.info.features);
if (this.info.features) {
this.info.features.forEach((f) => {
const featureName = this._getFeatureNameFromInfo(f);
const baseFeatureMeta = baseEntity.features[featureName];
if (baseFeatureMeta && !Array.isArray(baseFeatureMeta)) {
// singleton feature
baseFeatures = baseFeatures.filter((bf) => this._getFeatureNameFromInfo(bf) !== featureName);
}
});
overrideInfo.features = baseFeatures.concat(this.info.features);
} else {
overrideInfo.features = baseFeatures;
}
}
if (!isEmpty(baseEntity.info.fields)) {
let fields = _.cloneDeep(baseEntity.info.fields);
overrideInfo.fields = { ...fields, ...this.info.fields };
}
if (baseEntity.info.key) {
overrideInfo.key = baseEntity.info.key;
}
if (baseEntity.info.indexes) {
let indexes = _.cloneDeep(baseEntity.info.indexes);
let uniqueIndexes = indexes.filter((index) => index.unique);
if (this.info.indexes) {
this.info.indexes.forEach((index) => {
//if unique scope changed
if (index.unique) {
uniqueIndexes.forEach((inheritedIndex) => {
const fields1 = _.castArray(index.fields);
const fields2 = _.castArray(inheritedIndex.fields);
if (_.intersection(fields1, fields2).length === fields2.length) {
//fully included
const pos = indexes.indexOf(inheritedIndex);
if (pos !== -1) {
indexes.splice(pos, 1);
}
}
});
}
});
indexes = indexes.concat(this.info.indexes);
}
overrideInfo.indexes = indexes;
}
if (baseEntity.info.associations) {
let assocs = _.cloneDeep(baseEntity.info.associations);
assocs = assocs.map((assoc) => {
if (assoc.destEntity === baseEntity.name) {
return {
...assoc,
destEntity: this.name,
};
} else {
/*
const destEntity = this.linker.loadEntity(this.xemlModule, assoc.destEntity);
destEntity.info.associations[assoc.field]
*/
}
return assoc;
});
if (this.info.associations) {
assocs = assocs.concat(this.info.associations);
}
overrideInfo.associations = assocs;
}
if (baseEntity.inputs) {
overrideInfo.inputs = { ...baseEntity.inputs, ...this.info.inputs };
}
if (baseEntity.views) {
overrideInfo.views = { ...baseEntity.views, ...this.info.views };
}
if (baseEntity.modifiers) {
overrideInfo.modifiers = [...baseEntity.modifiers, ...this.info.modifiers];
}
if (!isEmpty(overrideInfo)) {
this.info = { ...this.info, ...overrideInfo };
}
}
}
module.exports = Entity;