diff --git a/packages/server/src/api/controllers/model.js b/packages/server/src/api/controllers/model.js index dabad28456..b046a2f3a8 100644 --- a/packages/server/src/api/controllers/model.js +++ b/packages/server/src/api/controllers/model.js @@ -19,12 +19,18 @@ exports.find = async function(ctx) { exports.save = async function(ctx) { const instanceId = ctx.user.instanceId const db = new CouchDB(instanceId) + const oldModelId = ctx.request.body._id const modelToSave = { type: "model", _id: newid(), views: {}, ...ctx.request.body, } + // get the model in its previous state for differencing + let oldModel = null + if (oldModelId) { + oldModel = await db.get(oldModelId) + } // rename record fields when table column is renamed const { _rename } = modelToSave @@ -70,8 +76,11 @@ exports.save = async function(ctx) { // update linked records await linkRecords.updateLinks({ instanceId, - eventType: linkRecords.EventType.MODEL_SAVE, + eventType: oldModel + ? linkRecords.EventType.MODEL_UPDATED + : linkRecords.EventType.MODEL_SAVE, model: modelToSave, + oldModel: oldModel, }) await db.put(designDoc) diff --git a/packages/server/src/db/linkedRecords/LinkController.js b/packages/server/src/db/linkedRecords/LinkController.js index 6c308a65d5..c43b116571 100644 --- a/packages/server/src/db/linkedRecords/LinkController.js +++ b/packages/server/src/db/linkedRecords/LinkController.js @@ -1,5 +1,5 @@ const CouchDB = require("../index") -const linkedRecords = require("./index") +const { IncludeDocs, getLinkDocuments } = require("./linkUtils") /** * Creates a new link document structure which can be put to the database. It is important to @@ -62,11 +62,14 @@ class LinkController { /** * Checks if the model this was constructed with has any linking columns currently. * If the model has not been retrieved this will retrieve it based on the eventData. + * @params {object|null} model If a model that is not known to the link controller is to be tested. * @returns {Promise} True if there are any linked fields, otherwise it will return * false. */ - async doesModelHaveLinkedFields() { - const model = await this.model() + async doesModelHaveLinkedFields(model = null) { + if (model == null) { + model = await this.model() + } for (let fieldName of Object.keys(model.schema)) { const { type } = model.schema[fieldName] if (type === "link") { @@ -79,12 +82,23 @@ class LinkController { /** * Utility function for main getLinkDocuments function - refer to it for functionality. */ - getLinkDocs(recordId = null) { - return linkedRecords.getLinkDocuments({ + getRecordLinkDocs(recordId, includeDocs = IncludeDocs.EXCLUDE) { + return getLinkDocuments({ instanceId: this._instanceId, modelId: this._modelId, recordId, - includeDocs: false, + includeDocs, + }) + } + + /** + * Utility function for main getLinkDocuments function - refer to it for functionality. + */ + getModelLinkDocs(includeDocs = IncludeDocs.EXCLUDE) { + return getLinkDocuments({ + instanceId: this._instanceId, + modelId: this._modelId, + includeDocs, }) } @@ -101,7 +115,7 @@ class LinkController { const record = this._record const operations = [] // get link docs to compare against - const linkVals = await this.getLinkDocs(record._id) + const linkVals = await this.getRecordLinkDocs(record._id) for (let fieldName of Object.keys(model.schema)) { // get the links this record wants to make const recordField = record[fieldName] @@ -155,15 +169,16 @@ class LinkController { async recordDeleted() { const record = this._record // need to get the full link docs to be be able to delete it - const linkDocIds = await this.getLinkDocs(record._id).map( - linkVal => linkVal.id + const linkDocs = await this.getRecordLinkDocs( + record._id, + IncludeDocs.INCLUDE ) - if (linkDocIds.length === 0) { + if (linkDocs.length === 0) { return null } - const toDelete = linkDocIds.map(id => { + const toDelete = linkDocs.map(doc => { return { - _id: id, + ...doc, _deleted: true, } }) @@ -171,6 +186,36 @@ class LinkController { return record } + /** + * Remove a field from a model as well as any linked records that pertained to it. + * @param {string} fieldName The field to be removed from the model. + * @returns {Promise} The model has now been updated. + */ + async removeFieldFromModel(fieldName) { + let model = await this.model() + let field = model.schema[fieldName] + const linkDocs = await this.getModelLinkDocs(IncludeDocs.INCLUDE) + let toDelete = linkDocs.filter(linkDoc => { + let correctFieldName = + linkDoc.doc1.modelId === model._id + ? linkDoc.doc1.fieldName + : linkDoc.doc2.fieldName + return correctFieldName === fieldName + }) + await this._db.bulkDocs( + toDelete.map(doc => { + return { + ...doc, + _deleted: true, + } + }) + ) + // remove schema from other model + let linkedModel = this._db.get(field.modelId) + delete linkedModel[field.fieldName] + this._db.put(linkedModel) + } + /** * When a model is saved this will carry out the necessary operations to make sure * any linked models are notified and updated correctly. @@ -198,6 +243,26 @@ class LinkController { return model } + /** + * Update a model, this means if a field is removed need to handle removing from other table and removing + * any link docs that pertained to it. + * @param {object} oldModel The model before it was updated which can be used for differencing. + * @returns {Promise} The model which has been saved, same response as with the modelSaved function. + */ + async modelUpdated(oldModel) { + // first start by checking if any link columns have been deleted + const newModel = await this.model() + for (let fieldName of Object.keys(oldModel.schema)) { + const field = oldModel.schema[fieldName] + // this field has been removed from the model schema + if (field.type === "link" && newModel.schema[fieldName] == null) { + await this.removeFieldFromModel(fieldName) + } + } + // now handle as if its a new save + return this.modelSaved() + } + /** * When a model is deleted this will carry out the necessary operations to make sure * any linked models have the joining column correctly removed as well as removing any @@ -217,14 +282,14 @@ class LinkController { } } // need to get the full link docs to delete them - const linkDocIds = await this.getLinkDocs().map(linkVal => linkVal.id) - if (linkDocIds.length === 0) { + const linkDocs = await this.getModelLinkDocs(IncludeDocs.INCLUDE) + if (linkDocs.length === 0) { return null } // get link docs for this model and configure for deletion - const toDelete = linkDocIds.map(id => { + const toDelete = linkDocs.map(doc => { return { - _id: id, + ...doc, _deleted: true, } }) diff --git a/packages/server/src/db/linkedRecords/index.js b/packages/server/src/db/linkedRecords/index.js index 7528d02f07..be501e3653 100644 --- a/packages/server/src/db/linkedRecords/index.js +++ b/packages/server/src/db/linkedRecords/index.js @@ -1,6 +1,5 @@ const LinkController = require("./LinkController") -const CouchDB = require("../index") -const Sentry = require("@sentry/node") +const { IncludeDocs, getLinkDocuments, createLinkView } = require("./linkUtils") /** * This functionality makes sure that when records with links are created, updated or deleted they are processed @@ -12,43 +11,15 @@ const EventType = { RECORD_UPDATE: "record:update", RECORD_DELETE: "record:delete", MODEL_SAVE: "model:save", + MODEL_UPDATED: "model:updated", MODEL_DELETE: "model:delete", } exports.EventType = EventType - -/** - * Creates the link view for the instance, this will overwrite the existing one, but this should only - * be called if it is found that the view does not exist. - * @param {string} instanceId The instance to which the view should be added. - * @returns {Promise} The view now exists, please note that the next view of this query will actually build it, - * so it may be slow. - */ -exports.createLinkView = async instanceId => { - const db = new CouchDB(instanceId) - const designDoc = await db.get("_design/database") - const view = { - map: function(doc) { - if (doc.type === "link") { - let doc1 = doc.doc1 - let doc2 = doc.doc2 - emit([doc1.modelId, doc1.recordId], { - id: doc2.recordId, - fieldName: doc1.fieldName, - }) - emit([doc2.modelId, doc2.recordId], { - id: doc1.recordId, - fieldName: doc2.fieldName, - }) - } - }.toString(), - } - designDoc.views = { - ...designDoc.views, - by_link: view, - } - await db.put(designDoc) -} +// re-export utils here for ease of use +exports.IncludeDocs = IncludeDocs +exports.getLinkDocuments = getLinkDocuments +exports.createLinkView = createLinkView /** * Update link documents for a record or model - this is to be called by the API controller when a change is occurring. @@ -56,8 +27,9 @@ exports.createLinkView = async instanceId => { * future quite easily (all updates go through one function). * @param {string} instanceId The ID of the instance in which the change is occurring. * @param {string} modelId The ID of the of the model which is being changed. - * * @param {object|null} record The record which is changing, e.g. created, updated or deleted. + * @param {object|null} record The record which is changing, e.g. created, updated or deleted. * @param {object|null} model If the model has already been retrieved this can be used to reduce database gets. + * @param {object|null} oldModel If the model is being updated then the old model can be provided for differencing. * @returns {Promise} When the update is complete this will respond successfully. Returns the record for * record operations and the model for model operations. */ @@ -67,6 +39,7 @@ exports.updateLinks = async ({ record, modelId, model, + oldModel, }) => { // make sure model ID is set if (model != null) { @@ -78,7 +51,11 @@ exports.updateLinks = async ({ model, record, }) - if (!(await linkController.doesModelHaveLinkedFields())) { + if ( + !(await linkController.doesModelHaveLinkedFields()) && + (oldModel == null || + !(await linkController.doesModelHaveLinkedFields(oldModel))) + ) { return record } switch (eventType) { @@ -89,6 +66,8 @@ exports.updateLinks = async ({ return await linkController.recordDeleted() case EventType.MODEL_SAVE: return await linkController.modelSaved() + case EventType.MODEL_UPDATED: + return await linkController.modelUpdated(oldModel) case EventType.MODEL_DELETE: return await linkController.modelDeleted() default: @@ -114,11 +93,11 @@ exports.attachLinkInfo = async (instanceId, records) => { // start by getting all the link values for performance reasons let responses = await Promise.all( records.map(record => - exports.getLinkDocuments({ + getLinkDocuments({ instanceId, modelId: record.modelId, recordId: record._id, - includeDocs: false, + includeDocs: IncludeDocs.EXCLUDE, }) ) ) @@ -141,50 +120,3 @@ exports.attachLinkInfo = async (instanceId, records) => { // otherwise return the first element as there was only one input return wasArray ? records : records[0] } - -/** - * Gets the linking documents, not the linked documents themselves. - * @param {string} instanceId The instance in which we are searching for linked records. - * @param {string} modelId The model which we are searching for linked records against. - * @param {string|null} fieldName The name of column/field which is being altered, only looking for - * linking documents that are related to it. If this is not specified then the table level will be assumed. - * @param {string|null} recordId The ID of the record which we want to find linking documents for - - * if this is not specified then it will assume model or field level depending on whether the - * field name has been specified. - * @param {boolean|null} includeDocs whether to include docs in the response call, this is considerably slower so only - * use this if actually interested in the docs themselves. - * @returns {Promise} This will return an array of the linking documents that were found - * (if any). - */ -exports.getLinkDocuments = async ({ - instanceId, - modelId, - recordId, - includeDocs, -}) => { - const db = new CouchDB(instanceId) - let params - if (recordId != null) { - params = { key: [modelId, recordId] } - } - // only model is known - else { - params = { startKey: [modelId], endKey: [modelId, {}] } - } - params.include_docs = !!includeDocs - try { - const response = await db.query("database/by_link", params) - if (includeDocs) { - return response.rows.map(row => row.doc) - } else { - return response.rows.map(row => row.value) - } - } catch (err) { - // check if the view doesn't exist, it should for all new instances - if (err != null && err.name === "not_found") { - await exports.createLinkView(instanceId) - } else { - Sentry.captureException(err) - } - } -} diff --git a/packages/server/src/db/linkedRecords/linkUtils.js b/packages/server/src/db/linkedRecords/linkUtils.js new file mode 100644 index 0000000000..2dbb4d3052 --- /dev/null +++ b/packages/server/src/db/linkedRecords/linkUtils.js @@ -0,0 +1,91 @@ +const CouchDB = require("../index") +const Sentry = require("@sentry/node") + +/** + * Only needed so that boolean parameters are being used for includeDocs + * @type {{EXCLUDE: boolean, INCLUDE: boolean}} + */ +exports.IncludeDocs = { + INCLUDE: true, + EXCLUDE: false, +} + +/** + * Creates the link view for the instance, this will overwrite the existing one, but this should only + * be called if it is found that the view does not exist. + * @param {string} instanceId The instance to which the view should be added. + * @returns {Promise} The view now exists, please note that the next view of this query will actually build it, + * so it may be slow. + */ +exports.createLinkView = async instanceId => { + const db = new CouchDB(instanceId) + const designDoc = await db.get("_design/database") + const view = { + map: function(doc) { + if (doc.type === "link") { + let doc1 = doc.doc1 + let doc2 = doc.doc2 + emit([doc1.modelId, doc1.recordId], { + id: doc2.recordId, + fieldName: doc1.fieldName, + }) + emit([doc2.modelId, doc2.recordId], { + id: doc1.recordId, + fieldName: doc2.fieldName, + }) + } + }.toString(), + } + designDoc.views = { + ...designDoc.views, + by_link: view, + } + await db.put(designDoc) +} + +/** + * Gets the linking documents, not the linked documents themselves. + * @param {string} instanceId The instance in which we are searching for linked records. + * @param {string} modelId The model which we are searching for linked records against. + * @param {string|null} fieldName The name of column/field which is being altered, only looking for + * linking documents that are related to it. If this is not specified then the table level will be assumed. + * @param {string|null} recordId The ID of the record which we want to find linking documents for - + * if this is not specified then it will assume model or field level depending on whether the + * field name has been specified. + * @param {boolean|null} includeDocs whether to include docs in the response call, this is considerably slower so only + * use this if actually interested in the docs themselves. + * @returns {Promise} This will return an array of the linking documents that were found + * (if any). + */ +exports.getLinkDocuments = async ({ + instanceId, + modelId, + recordId, + includeDocs, +}) => { + const db = new CouchDB(instanceId) + let params + if (recordId != null) { + params = { key: [modelId, recordId] } + } + // only model is known + else { + params = { startKey: [modelId], endKey: [modelId, {}] } + } + params.include_docs = !!includeDocs + try { + const response = await db.query("database/by_link", params) + if (includeDocs) { + return response.rows.map(row => row.doc) + } else { + return response.rows.map(row => row.value) + } + } catch (err) { + // check if the view doesn't exist, it should for all new instances + if (err != null && err.name === "not_found") { + await exports.createLinkView(instanceId) + } else { + Sentry.captureException(err) + } + } +}