UNPKG

@commodo/fields-storage-ref

Version:

We're working hard to get all the docs in order. New articles will be added daily.

1,091 lines (889 loc) 36.7 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _name = require("@commodo/name"); var _repropose = require("repropose"); var _fieldsStorage = require("@commodo/fields-storage"); var _fields = require("@commodo/fields"); var _ = require("."); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } var _default = ({ list, instanceOf, using, autoDelete, autoSave, refNameField, parent, loadRefs, loadRef }) => { return (0, _repropose.withProps)(props => { const { setValue, isDirty, validate, clean } = props; return { list, using, options: { refNameField }, queue: [], // Contains initial value received upon loading from storage. If the current value becomes different from initial, // upon save, old entity must be removed. This is only active when auto delete option on the field is enabled, // which then represents a one to one relationship. initial: null, classes: null, auto: null, init() { if (parent) { this.parent = parent; } (0, _.checkParent)(this); if (list) { this.current = new _fieldsStorage.Collection(); this.initial = new _fieldsStorage.Collection(); this.links = { dirty: false, set: false, current: new _fieldsStorage.Collection(), initial: new _fieldsStorage.Collection() }; this.classes = { parent: (0, _name.getName)(this.parent), models: { class: null, field: null }, using: { class: null, field: null } }; if (Array.isArray(instanceOf)) { if (typeof instanceOf[1] === "string") { this.classes.models.class = instanceOf[0]; this.classes.models.field = instanceOf[1]; } else { this.classes.models.class = instanceOf; } } else { this.classes.models.class = instanceOf; } // We will use the same value here to (when loading models without a middle aggregation model). if (!this.classes.models.field) { this.classes.models.field = (0, _.firstCharacterToLower)(this.classes.parent); } if (using) { if (Array.isArray(using)) { this.setUsing(...using); } else { this.setUsing(using); } } /** * Auto save and delete are both enabled by default. * @type {{save: boolean, delete: boolean}} */ this.auto = { save: typeof autoSave === "undefined" ? true : autoSave, delete: typeof autoDelete === "undefined" ? true : autoDelete }; /** * By default, we don't want to have links stored in model field directly. * @var bool */ this.toStorage = false; this.parent.hook("__save", async () => { // If loading is in progress, wait until loaded. const mustManage = this.isDirty() || this.state.loading; if (!mustManage) { return; } await this.load(); await this.normalizeSetValues(); if (this.getUsingClass()) { // Do we have to manage models? // If so, this will ensure that newly set or unset models and its link models are synced. // "syncCurrentEntitiesAndLinks" method must be called on this event because link models must be ready // before the validation of data happens. When validation happens and when link class is set, // validation is triggered on link (aggregation) model, not on model end (linked) model. await this.manageCurrentLinks(); } else { await this.manageCurrent(); } }); /** * Same as in non-list ref field, models present here were already validated when parent model called the validate method. * At this point, models are ready to be saved (only loaded models). */ this.parent.hook("__afterSave", async () => { // We don't have to do the following check here: // this.value.isLoading() && (await this.value.load()); // ...it was already made in the 'save' handler above. Now we only check if not loaded. if (!this.state.loaded || !this.isDirty()) { return; } if (this.getAutoSave()) { // If we are using a link class, we only need to save links, and child models will be automatically // saved if they were loaded. if (this.getUsingClass()) { const models = this.links.current; for (let i = 0; i < models.length; i++) { const current = models[i]; await current.save({ validation: false }); } } else { const models = this.current; for (let i = 0; i < models.length; i++) { const current = models[i]; await current.save({ validation: false }); } } if (this.getAutoDelete()) { this.getUsingClass() ? await this.deleteInitialLinks() : await this.deleteInitial(); } } // Set current models as new initial values. this.syncInitial(); if (this.getUsingClass()) { this.syncInitialLinks(); } }); this.parent.hook("delete", async () => { if (this.getAutoDelete()) { await this.load(); const models = { current: this.getUsingClass() ? this.links.current : this.current, class: this.getUsingClass() || this.getEntitiesClass() }; for (let i = 0; i < models.current.length; i++) { if ((0, _.instanceOf)(models.current[i], models.class)) { await models.current[i].hook("delete"); } } } }); this.parent.hook("beforeDelete", async () => { if (this.getAutoDelete()) { await this.load(); const models = { current: this.getUsingClass() ? this.links.current : this.current, class: this.getUsingClass() || this.getEntitiesClass() }; for (let i = 0; i < models.current.length; i++) { if ((0, _.instanceOf)(models.current[i], models.class)) { await models.current[i].delete({ events: { delete: false } }); } } } }); } else { /** * Auto save is always enabled, but delete not. This is because users will more often create many to one relationship than * one to one. If user wants a strict one to one relationship, then delete flag must be set to true. In other words, it would * be correct to say that if auto delete is enabled, we are dealing with one to one relationship. * @type {{save: boolean, delete: boolean}} */ this.auto = { save: typeof autoSave === "undefined" ? true : autoSave, delete: typeof autoDelete === "undefined" ? false : autoDelete }; this.classes = { model: { class: instanceOf } }; /** * Before save, let's validate and save linked model. * * This ensures that parent model has a valid ID which can be stored and also that all nested data is valid since * validation will be called internally in the save method. Save operations will be executed starting from bottom * nested models, ending with the main parent model. */ this.parent.hook("__beforeSave", async () => { // At this point current value is an instance or is not instance. It cannot be in the 'loading' state, because that was // already checked in the validate method - if in that step model was in 'loading' state, it will be waited before proceeding. if (this.getAutoSave()) { // We don't need to validate here because validate method was called on the parent model, which caused // the validation of data to be executed recursively on all field values. if ((0, _fields.hasFields)(this.current)) { await this.current.save({ validation: false }); } // If initially we had a different model linked, we must delete it. // If initial is empty, that means nothing was ever loaded (field was not accessed) and there is nothing to do. // Otherwise, deleteInitial method will internally delete only models that are not needed anymore. if (this.getAutoDelete()) { await this.deleteInitial(this.auto.delete.options); } } // Set current models as new initial values. this.syncInitial(); }); /** * Once parent model starts the delete process, we must also make the same on all linked models. * The deletes are done on initial storage models, not on models stored as current value. */ this.parent.hook("delete", async () => { if (this.getAutoDelete()) { await this.load(); const model = this.initial; if ((0, _.instanceOf)(model, instanceOf)) { await model.hook("delete"); } } }); this.parent.hook("beforeDelete", async () => { if (this.getAutoDelete()) { await this.load(); const model = this.initial; if ((0, _.instanceOf)(model, instanceOf)) { // We don't want to fire the "delete" event because its handlers were already executed by upper 'delete' listener. // That listener ensured that all callbacks that might've had blocked the deleted process were executed. await model.delete({ validation: false, hooks: { delete: false } }); } } }); } }, /** * Should linked model be automatically saved once parent model is saved? By default, linked models will be automatically saved, * after main model was saved. Can be disabled, although not recommended since manual saving needs to be done in that case. * @param enabled * @param options */ setAutoSave(enabled = true) { this.auto.save = enabled; return this; }, /** * Returns true if auto save is enabled, otherwise false. * @returns {boolean} */ getAutoSave() { return this.auto.save; }, /** * Should linked model be automatically deleted once parent model is deleted? By default, linked models will be automatically * deleted, before main model was deleted. Can be disabled, although not recommended since manual deletion needs to be done in that case. * @param enabled */ setAutoDelete(enabled = true) { this.auto.delete = enabled; return this; }, /** * Returns true if auto delete is enabled, otherwise false. * @returns {boolean} */ getAutoDelete() { return this.auto.delete; }, getEntityClass() { if (this.list) { return this.classes.models.class; } if (Array.isArray(this.classes.model.class)) { let refNameField = this.parent.getField(this.options.refNameField); if (refNameField) { const modelName = refNameField.getValue(); for (let i = 0; i < this.classes.model.class.length; i++) { let current = this.classes.model.class[i]; if ((0, _name.getName)(current) === modelName) { return current; } } } return undefined; } return this.classes.model.class; }, getEntityClasses() { return this.classes.model.class; }, getRefNameField() { return this.parent.getField(this.options.refNameField); }, hasMultipleEntityClasses() { return Array.isArray(this.classes.model.class); }, canAcceptAnyEntityClass() { return this.hasMultipleEntityClasses() && this.classes.model.class.length === 0; }, setEntityClass(model) { this.classes.model.class = model; return this; }, setValue(value, options = { skipDifferenceCheck: true, forceSetAsDirty: true }) { if (this.list) { return setValue.call(this, value, options); } setValue.call(this, value); // If we are dealing with multiple Entity classes, we must assign received classId into // field specified by the "refNameField" option (passed on field construction). const refNameField = this.getRefNameField(); if (refNameField && this.hasMultipleEntityClasses()) { if ((0, _fields.hasFields)(value)) { return refNameField.setValue((0, _name.getName)(value)); } if (!value) { return refNameField.setValue(null); } } }, /** * Loads current model if needed and returns it. * @returns {Promise<void>} */ async getValue() { if (!this.isDirty()) { await this.load(); } if (list) { await this.normalizeSetValues(); return this.current; } // "Instance of Entity" check is enough at this point. if ((0, _fields.hasFields)(this.current)) { return this.current; } const modelClass = this.getEntityClass(); if (!modelClass) { return this.current; } const id = (0, _.getIdFromValue)(this.current); if (this.parent.isId(id)) { const model = await modelClass.findById(id); if (model) { // If we initially had object with other data set, we must populate model with it, otherwise // just set loaded model (because only an ID was received, without additional data). const current = this.current; if (current instanceof Object) { model.populate(current); } this.state.set = true; this.current = model; } return this.current; } const current = this.current; if (current instanceof Object) { const model = new modelClass().populate(current); this.state.set = true; this.current = model; } // If valid value was not returned until this point, we return recently set value. // The reason is, if the model is about to be saved, validation must be executed and error must be thrown, // warning users that passed value is invalid / model was not found. return this.current; }, /** * Returns storage value (model ID or null). * @returns {Promise<*>} */ async getStorageValue() { // Not using getValue method because it would load the model without need. let current = this.current; // But still, if the value is loading currently, let's wait for it to load completely, and then use that value. if (this.state.loading) { current = await this.load(); } const id = (0, _.getIdFromValue)(current); return this.parent.isId(id) ? id : null; }, /** * Sets value received from storage. * @param value * @returns {EntityField} */ setStorageValue(value) { if (list) { return; } this.current = value; this.initial = value; this.state.set = true; return this; }, async refsValidateValue(value) { const errors = []; const correctClass = this.getUsingClass() || this.getEntitiesClass(); if (!Array.isArray(value)) { return; } for (let i = 0; i < value.length; i++) { const currentEntity = value[i]; if (!(0, _.instanceOf)(currentEntity, correctClass)) { errors.push({ code: _fields.WithFieldsError.VALIDATION_FAILED_INVALID_FIELD, data: { index: i }, message: `Validation failed, item at index ${i} not an instance of correct Entity class.` }); continue; } try { await currentEntity.validate(); } catch (e) { errors.push({ code: e.code, data: _objectSpread({ index: i }, e.data), message: e.message }); } } if (errors.length > 0) { throw new _fields.WithFieldsError("Validation failed.", _fields.WithFieldsError.VALIDATION_FAILED_INVALID_FIELD, errors); } }, /** * Validates on attribute level and then on model level (its attributes recursively). * If attribute has validators, we must unfortunately always load the attribute value. For example, if we had a 'required' * validator, and model not loaded, we cannot know if there is a value or not, and thus if the validator should fail. * @returns {Promise<void>} */ async refsValidate() { // If attribute has validators or loading is in progress, wait until loaded. const mustValidate = this.isDirty() || this.validation || this.state.loading; if (!mustValidate) { return; } await this.load(); await this.normalizeSetValues(); const value = this.getUsingClass() ? this.links.current : this.current; const notEmpty = !this.isEmpty(); await validate.call(this); notEmpty && (await this.refsValidateValue(value)); }, /** * Validates on field level and then on model level (its fields recursively). * If field has validators, we must unfortunately always load the field value. For example, if we had a 'required' * validator, and model not loaded, we cannot know if there is a value or not, and thus if the validator should fail. * @returns {Promise<void>} */ async validate() { if (this.list) { return this.refsValidate(); } // If field is dirty, has validators or loading is in progress, wait until loaded. if (this.isDirty() || this.validation || this.state.loading) { await this.load(); } if (!this.state.loaded) { return; } const value = await this.getValue(); const notEmpty = !this.isEmpty(); if (notEmpty && this.hasMultipleEntityClasses()) { if (!this.options.refNameField) { throw new _fields.WithFieldsError(`Entity field "${this.name}" accepts multiple Entity classes but does not have "refNameField" option defined.`, _fields.WithFieldsError.VALIDATION_FAILED_INVALID_FIELD); } let refNameField = this.getRefNameField(); if (!refNameField) { throw new _fields.WithFieldsError(`Entity field "${this.name}" accepts multiple Entity classes but classId field is missing.`, _fields.WithFieldsError.VALIDATION_FAILED_INVALID_FIELD); } // We only do class validation if list of classes has been provided. Otherwise, we don't do the check. // This is because in certain cases, a list of classes cannot be defined, and in other words, any // class of model can be assigned. One example is the File model, which has an "ref" field, which // can actually link to any type of model. if (!this.canAcceptAnyEntityClass()) { if (!this.getEntityClass()) { const heldValue = await refNameField.getValue(); if (!(typeof heldValue === "string")) { throw new _fields.WithFieldsError(`Entity field "${this.name}" accepts multiple Entity classes but it was not found (classId field holds invalid non-string value).`, _fields.WithFieldsError.VALIDATION_FAILED_INVALID_FIELD); } throw new _fields.WithFieldsError(`Entity field "${this.name}" accepts multiple Entity classes but it was not found (classId field holds value "${heldValue}").`, _fields.WithFieldsError.VALIDATION_FAILED_INVALID_FIELD); } } } if (notEmpty && !this.isValidInstance(value)) { throw new _fields.WithFieldsError(`Validation failed, received ${typeof value}, expecting instance a valid reference.`, _fields.WithFieldsError.VALIDATION_FAILED_INVALID_FIELD); } await validate.call(this); notEmpty && (await value.validate()); }, isValidInstance(instance) { if (this.hasMultipleEntityClasses()) { return (0, _fields.hasFields)(instance); } return (0, _.instanceOf)(instance, instanceOf); }, async load() { if (this.list) { return this.loadRefs(); } return this.loadRef(); }, async loadRefs() { if (this.state.loading) { return new Promise(resolve => this.queue.push(resolve)); } if (this.state.loaded) { return; } const classes = this.classes; this.state.loading = true; if (this.parent.isExisting()) { let id = await this.parent.getField("id").getValue(); if (classes.using.class) { if (typeof loadRefs === "function") { this.links.initial = loadRefs({ Model: classes.using.class, field: classes.models.field, id }); } else { this.links.initial = await classes.using.class.find({ query: { [classes.models.field]: id } }); } this.initial = new _fieldsStorage.Collection(); for (let i = 0; i < this.links.initial.length; i++) { this.initial.push(await this.links.initial[i][classes.using.field]); } } else { if (typeof loadRefs === "function") { this.initial = loadRefs({ Model: classes.models.class, field: classes.models.field, id }); } else { this.initial = await classes.models.class.find({ query: { [classes.models.field]: id } }); } } if (!this.isDirty()) { const initial = this.initial; const initialLinks = this.links.initial; if (Array.isArray(initial) && Array.isArray(initialLinks)) { this.state.set = true; this.current = new _fieldsStorage.Collection(initial); if (classes.using.class) { this.links.set = true; this.links.current = new _fieldsStorage.Collection(initialLinks); } } } } this.state.loading = false; this.state.loaded = true; await this.__executeQueue(); return this.current; }, /** * Ensures data is loaded correctly, and in the end returns current value. * @returns {Promise<*>} */ async loadRef() { if (this.state.loading) { return new Promise(resolve => { this.queue.push(resolve); }); } if (this.state.loaded) { return; } this.state.loading = true; // Only if we have a valid ID set, we must load linked entity. const initial = this.initial; if (this.parent.isId(initial)) { const modelClass = this.getEntityClass(); if (modelClass) { let entity; if (typeof loadRef === "function") { entity = await loadRef({ Model: modelClass, id: initial }); this.initial = entity; } else { entity = await modelClass.findById(initial); this.initial = entity; } // If current value is not dirty, than we can set initial value as current, otherwise we // assume that something else was set as current value like a new entity. if (!this.isDirty()) { this.state.set = true; this.current = entity; } } } this.state.loading = false; this.state.loaded = true; await this.__executeQueue(); return this.current; }, async deleteInitial(options) { if (!this.hasInitial()) { return; } if (this.list) { const initial = this.initial, currentEntitiesIds = this.current.map(model => (0, _.getIdFromValue)(model)); for (let i = 0; i < initial.length; i++) { const currentInitial = initial[i]; if ((0, _fields.hasFields)(currentInitial)) { if (!currentEntitiesIds.includes(currentInitial.id)) { await currentInitial.delete(); } } } return; } // Initial value will always be an existing (already saved) Entity instance. const initial = this.initial; if ((0, _fields.hasFields)(initial) && (0, _.getIdFromValue)(initial) !== (0, _.getIdFromValue)(this.current)) { await initial.delete(options); } }, syncInitial() { if (this.list) { this.initial = new _fieldsStorage.Collection([...this.current]); return; } this.initial = this.current; }, hasInitial() { if (this.list) { return this.initial.length > 0; } return (0, _.instanceOf)(this.initial, this.getEntityClass()); }, isDirty() { if (this.list) { if (isDirty.bind(this)()) { return true; } if (Array.isArray(this.current)) { for (let i = 0; i < this.current.length; i++) { if ((0, _fields.hasFields)(this.current[i]) && this.current[i].isDirty()) { return true; } } } return false; } if (isDirty.bind(this)()) { return true; } return (0, _fields.hasFields)(this.current) && this.current.isDirty(); }, async manageCurrent() { const current = this.current; for (let i = 0; i < current.length; i++) { const model = current[i]; if ((0, _fields.hasFields)(model)) { const classes = this.classes; model[classes.models.field] = this.parent; } } }, /** * Value cannot be set as clean if there is no ID present. */ clean() { if ((0, _.getIdFromValue)(this.current)) { clean.call(this); } return this; }, isDifferentFrom(value) { const currentId = (0, _.getIdFromValue)(this.current); if ((0, _fields.hasFields)(value)) { return !value.id || value.id !== currentId; } if (value instanceof Object) { if (!value.id) { return true; } return value.id !== currentId || Object.keys(value).length > 1; } return currentId !== value; }, async __executeQueue() { if (this.queue.length) { for (let i = 0; i < this.queue.length; i++) { await this.queue[i](); } this.queue = []; } }, // tu krece entities async normalizeSetValues() { // Before returning, let's load all values. const models = this.current; if (!Array.isArray(models)) { return; } for (let i = 0; i < models.length; i++) { let current = models[i]; // "Instance of Entity" check is enough at this point. if ((0, _fields.hasFields)(current)) { continue; } const modelClass = this.getEntitiesClass(); if (!modelClass) { continue; } const id = (0, _.getIdFromValue)(current); if (this.parent.isId(id)) { const model = await modelClass.findById(id); if (model) { // If we initially had object with other data set, we must populate model with it, otherwise // just set loaded model (because only an ID was received, without additional data). if (current instanceof Object) { model.populate(current); } models[i] = model; } // TODO: Could be a bug, what happens with object that wasn't loaded properly? // TODO: We throw exception? continue; } if (current instanceof Object) { models[i] = new modelClass().populate(current); } } }, getEntitiesClass() { return this.classes.models.class; }, getUsingClass() { let modelsClass = this.classes.using.class; if (!modelsClass) { return null; } if (modelsClass.name) { return modelsClass; } if (typeof modelsClass === "function") { return modelsClass(); } }, getEntitiesField() { return this.classes.models.field; }, getUsingField() { return this.classes.using.field; }, setUsing(modelClass, modelField) { this.classes.using.class = modelClass; if (typeof modelField === "undefined") { this.classes.using.field = (0, _.firstCharacterToLower)((0, _name.getName)(this.classes.models.class)); } else { this.classes.using.field = modelField; } return this; }, async deleteInitialLinks() { // If initial is empty, that means nothing was ever loaded (attribute was not accessed) and there is nothing to do. // Otherwise, deleteInitial method will internally delete only models that are not needed anymore. if (!this.links.initial.length) { return; } const initialLinks = this.links.initial, // $FlowFixMe currentLinksIds = this.links.current.map(model => model.id); for (let i = 0; i < initialLinks.length; i++) { const initial = initialLinks[i]; // $FlowFixMe if (!currentLinksIds.includes(initial.id)) { (0, _fields.hasFields)(initial) && (await initial.delete()); } } }, /** * Creates a new array that contains all currently loaded models. */ syncInitialLinks() { this.links.initial = [...this.links.current]; }, /** * Sets current links, based on initial and currently set models. * * How links-management works? * When models are set, on "__save" event, attribute will be first loaded - meaning all initial (from storage) * linked models and its links will be loaded ("this.initial" / "this.links.initial"). After that, this method * will iterate over all newly set models, and check if for each a link is already existing. If so, it will * use it, otherwise a new instance is created, linking parent and set model together. * * Additional note: previously, there was an idea that link models could also contain additional information. * This still could works for lists in which models are all unique, meaning all models show only once in * the list. In cases where a single model can appear more than once, this might not be the best solution, since * linking problems can appear. * * Eg. if user has a list of models: A - A - B - C, and if links have a specific information, reordering * first two A models wouldn't make a difference, and nothing would be updated. * * But generally, this is a bad approach to have, in cases where links need to have additional data, a new model * would have to be made, linking the A product and containing all needed information. * * Basic example of this is a cart, with added products. Added product might appear many times, in different * colors and sizes, so here it would be best to just create CartItem model, that links the product and contains * needed information. * * Link models can be extended with additional attributes where it's sure that no duplicates can occur. * @returns {Promise<void>} */ async manageCurrentLinks() { const links = [], current = this.current, currentLinks = this.links.initial; for (let i = 0; i < current.length; i++) { const currentEntity = current[i]; // Following chunk actually represents: "_.find(currentLinks, link => link.<model> === current);". // "for" loop used because of async operations. let link = null; for (let j = 0; j < currentLinks.length; j++) { // $FlowFixMe const linkedEntity = await currentLinks[j][this.getUsingField()]; if (linkedEntity === currentEntity) { link = currentLinks[j]; break; } } // If model has an already existing link instance, it will be used. Otherwise a new instance will be created. // Links array cannot contain two same instances. if (link && !links.includes(link)) { links.push(link); } else { const attr = {}; attr.usingClass = this.getUsingClass(); attr.usingAttribute = this.getUsingField(); attr.modelsAttribute = this.getEntitiesField(); if (attr.usingClass && attr.usingAttribute && attr.modelsAttribute) { const model = new attr.usingClass(); model[attr.usingAttribute] = currentEntity; model[attr.modelsAttribute] = this.parent; links.push(model); } } } this.links.set = true; if (this.isDifferentFrom(links)) { this.links.dirty = true; } this.links.current = links; } }; }); }; exports.default = _default; //# sourceMappingURL=withRefProps.js.map