diff --git a/packages/server/src/api/controllers/model.js b/packages/server/src/api/controllers/model.js index b2776650e9..990a36541c 100644 --- a/packages/server/src/api/controllers/model.js +++ b/packages/server/src/api/controllers/model.js @@ -1,5 +1,6 @@ const CouchDB = require("../../db") const newid = require("../../db/newid") +const { EventType, updateLinksForModel } = require("../../db/linkedRecords") exports.fetch = async function(ctx) { const db = new CouchDB(ctx.user.instanceId) @@ -53,21 +54,6 @@ exports.save = async function(ctx) { const result = await db.post(modelToSave) modelToSave._rev = result.rev - const { schema } = ctx.request.body - for (let key of Object.keys(schema)) { - // model has a linked record - if (schema[key].type === "link") { - // create the link field in the other model - const linkedModel = await db.get(schema[key].modelId) - linkedModel.schema[modelToSave.name] = { - name: modelToSave.name, - type: "link", - modelId: modelToSave._id, - } - await db.put(linkedModel) - } - } - const designDoc = await db.get("_design/database") designDoc.views = { ...designDoc.views, @@ -80,6 +66,12 @@ exports.save = async function(ctx) { }, } await db.put(designDoc) + // update linked records + await updateLinksForModel({ + instanceId, + eventType: EventType.MODEL_SAVE, + model: modelToSave, + }) // syntactic sugar for event emission modelToSave.modelId = modelToSave._id @@ -106,20 +98,16 @@ exports.destroy = async function(ctx) { records.rows.map(record => ({ _id: record.id, _deleted: true })) ) - // Delete linked record fields in dependent models - for (let key of Object.keys(modelToDelete.schema)) { - const { type, modelId } = modelToDelete.schema[key] - if (type === "link") { - const linkedModel = await db.get(modelId) - delete linkedModel.schema[modelToDelete.name] - await db.put(linkedModel) - } - } - // delete the "all" view const designDoc = await db.get("_design/database") delete designDoc.views[modelViewId] await db.put(designDoc) + // update linked records + await updateLinksForModel({ + instanceId, + eventType: EventType.MODEL_DELETE, + model: modelToDelete, + }) // syntactic sugar for event emission modelToDelete.modelId = modelToDelete._id diff --git a/packages/server/src/api/controllers/record.js b/packages/server/src/api/controllers/record.js index a2ac3ea2f5..11fbc811b9 100644 --- a/packages/server/src/api/controllers/record.js +++ b/packages/server/src/api/controllers/record.js @@ -1,6 +1,11 @@ const CouchDB = require("../../db") const validateJs = require("validate.js") const newid = require("../../db/newid") +const { + EventType, + updateLinksForRecord, + getLinkDocuments, +} = require("../../db/linkedRecords") validateJs.extend(validateJs.validators.datetime, { parse: function(value) { @@ -41,6 +46,14 @@ exports.patch = async function(ctx) { const response = await db.put(record) record._rev = response.rev record.type = "record" + await updateLinksForRecord({ + instanceId, + eventType: EventType.RECORD_UPDATE, + record, + modelId: record.modelId, + model, + }) + ctx.eventEmitter && ctx.eventEmitter.emitRecord(`record:update`, instanceId, record, model) ctx.body = record @@ -89,29 +102,13 @@ exports.save = async function(ctx) { record.type = "record" const response = await db.post(record) record._rev = response.rev - - // create links in other tables - for (let key in record) { - if (model.schema[key] && model.schema[key].type === "link") { - const linked = await db.allDocs({ - include_docs: true, - keys: record[key], - }) - - // add this record to the linked records in attached models - const linkedDocs = linked.rows.map(row => { - const doc = row.doc - return { - ...doc, - [model.name]: doc[model.name] - ? [...doc[model.name], record._id] - : [record._id], - } - }) - - await db.bulkDocs(linkedDocs) - } - } + await updateLinksForRecord({ + instanceId, + eventType: EventType.RECORD_SAVE, + record, + modelId: record.modelId, + model, + }) ctx.eventEmitter && ctx.eventEmitter.emitRecord(`record:save`, instanceId, record, model) @@ -179,6 +176,13 @@ exports.destroy = async function(ctx) { } ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId) ctx.status = 200 + await updateLinksForRecord({ + instanceId, + eventType: EventType.RECORD_DELETE, + record, + modelId: record.modelId, + }) + // for automations include the record that was deleted ctx.record = record ctx.eventEmitter && @@ -201,7 +205,7 @@ async function validate({ instanceId, modelId, record, model }) { model = await db.get(modelId) } const errors = {} - for (let fieldName in model.schema) { + for (let fieldName of Object.keys(model.schema)) { const res = validateJs.single( record[fieldName], model.schema[fieldName].constraints @@ -210,3 +214,28 @@ async function validate({ instanceId, modelId, record, model }) { } return { valid: Object.keys(errors).length === 0, errors } } + +exports.fetchLinkedRecords = async function(ctx) { + const instanceId = ctx.user.instanceId + const modelId = ctx.params.modelId + const fieldName = ctx.params.fieldName + const recordId = ctx.params.recordId + if (instanceId == null || modelId == null || recordId == null) { + ctx.status = 400 + ctx.body = { + status: 400, + error: + "Cannot handle request, URI params have not been successfully prepared.", + } + return + } + let records = await getLinkDocuments({ + instanceId, + modelId, + fieldName, + recordId, + includeDoc: true, + }) + ctx.status = 200 + ctx.body = { records: records } +} diff --git a/packages/server/src/api/routes/tests/model.spec.js b/packages/server/src/api/routes/tests/model.spec.js index 23214b95c3..dc3fb7bf68 100644 --- a/packages/server/src/api/routes/tests/model.spec.js +++ b/packages/server/src/api/routes/tests/model.spec.js @@ -45,7 +45,7 @@ describe("/models", () => { .expect(200) .end(async (err, res) => { expect(res.res.statusMessage).toEqual("Model TestModel saved successfully."); - expect(res.body.name).toEqual("TestModel"); + expect(res.body.name).toEqual("TestModel"); done(); }); }) @@ -201,10 +201,10 @@ describe("/models", () => { .expect('Content-Type', /json/) .expect(200) .end(async (_, res) => { - expect(res.res.statusMessage).toEqual(`Model ${testModel._id} deleted.`); - const dependentModel = await getDocument(instance._id, linkedModel._id) - expect(dependentModel.schema.TestModel).not.toBeDefined(); - done(); + expect(res.res.statusMessage).toEqual(`Model ${testModel._id} deleted.`); + const dependentModel = await getDocument(instance._id, linkedModel._id) + expect(dependentModel.schema.TestModel).not.toBeDefined(); + done(); }); }) diff --git a/packages/server/src/db/linkedRecords/LinkController.js b/packages/server/src/db/linkedRecords/LinkController.js index 7125aad990..983155e4b9 100644 --- a/packages/server/src/db/linkedRecords/LinkController.js +++ b/packages/server/src/db/linkedRecords/LinkController.js @@ -21,6 +21,10 @@ function LinkDocument( fieldName2, recordId2 ) { + // build the ID out of unique references to this link document + this._id = `${modelId1}/${modelId2}/${fieldName1}/${fieldName2}/${recordId1}/${recordId2}` + // required for referencing in view + this.type = "link" this.doc1 = { modelId: modelId1, fieldName: fieldName1, @@ -34,18 +38,12 @@ function LinkDocument( } class LinkController { - /** - * Create a new link controller which can be used to handle link updates for an event. - * @param {string} instanceId The instance in which updates will be carried out. - * @param {{modelId: string, model: object|undefined, record: object|undefined}} eventData data about - * what has occurred to drive this update - events are emitted when an operation that matters occurs. - */ - constructor(instanceId, eventData) { + constructor({ instanceId, modelId, record, model }) { this._instanceId = instanceId this._db = new CouchDB(instanceId) - this._modelId = eventData.modelId - this._record = eventData.record - this._model = eventData.model + this._modelId = modelId + this._record = record + this._model = model } /** @@ -106,9 +104,8 @@ class LinkController { const field = model.schema[fieldName] if (field.type === "link") { // get link docs to compare against - let currentLinkIds = await this.getLinkDocs(fieldName, record._id).map( - doc => doc._id - ) + let linkDocs = await this.getLinkDocs(fieldName, record._id) + let currentLinkIds = linkDocs.map(doc => doc._id) let toLinkIds = record[fieldName] for (let linkId of toLinkIds) { if (currentLinkIds.indexOf(linkId) === -1) { @@ -144,7 +141,11 @@ class LinkController { async recordDeleted() { const record = this._record // get link docs to compare against - let toDelete = await this.getLinkDocs(null, record._id).map(doc => { + let linkDocs = await this.getLinkDocs(null, record._id) + if (linkDocs.length === 0) { + return null + } + let toDelete = linkDocs.map(doc => { return { ...doc, _deleted: true, @@ -196,8 +197,13 @@ class LinkController { await this._db.put(linkedModel) } } + // get link docs to compare against + let linkDocs = await this.getLinkDocs() + if (linkDocs.length === 0) { + return null + } // get link docs for this model and configure for deletion - let toDelete = await this.getLinkDocs().map(doc => { + let toDelete = linkDocs.map(doc => { return { ...doc, _deleted: true, diff --git a/packages/server/src/db/linkedRecords/index.js b/packages/server/src/db/linkedRecords/index.js index 47c578eff4..a4025ea90c 100644 --- a/packages/server/src/db/linkedRecords/index.js +++ b/packages/server/src/db/linkedRecords/index.js @@ -1,5 +1,3 @@ -const emitter = require("../../events") -const InMemoryQueue = require("../../utilities/queue/inMemoryQueue") const LinkController = require("./LinkController") const CouchDB = require("../index") @@ -16,55 +14,85 @@ const EventType = { MODEL_DELETE: "model:delete", } -const linkedRecordQueue = new InMemoryQueue("linkedRecordQueue") +module.exports.EventType = EventType -function createEmitterCallback(eventName) { - emitter.on(eventName, function(event) { - if (!event || !event.record || !event.record.modelId) { - return - } - linkedRecordQueue.add({ - type: eventName, - event, - }) - }) -} - -for (let typeKey of Object.keys(EventType)) { - createEmitterCallback(EventType[typeKey]) -} - -linkedRecordQueue.process(async job => { - let data = job.data +/** + * Update link documents for a model - this is to be called by the model controller when a model is being changed. + * @param {EventType} eventType states what type of model change is occurring, means this can be expanded upon in the + * future quite easily (all updates go through one function). + * @param {string} instanceId The ID of the instance in which the model change is occurring. + * @param {object} model The model which is changing, whether it is being deleted, created or updated. + * @returns {Promise} When the update is complete this will respond successfully. + */ +module.exports.updateLinksForModel = async ({ + eventType, + instanceId, + model, +}) => { // can't operate without these properties - if (data.instanceId == null || data.modelId == null) { - return + if (instanceId == null || model == null) { + return null } - // link controller exists to help manage state, the operation - // of updating links is a particularly stateful task - let linkController = new LinkController(data.instanceId, data) - // model doesn't have links, can stop here + let linkController = new LinkController({ + instanceId, + modelId: model._id, + model, + }) if (!(await linkController.doesModelHaveLinkedFields())) { - return + return null } - // carry out the logic at a top level so that we can handle - // multiple operations for a single queue entry if desired - switch (data.type) { - case EventType.RECORD_SAVE: - case EventType.RECORD_UPDATE: - await linkController.recordSaved() - break - case EventType.RECORD_DELETE: - await linkController.recordDeleted() - break + switch (eventType) { case EventType.MODEL_SAVE: await linkController.modelSaved() break case EventType.MODEL_DELETE: await linkController.modelDeleted() break + default: + throw "Type of event is not known, linked record handler requires update." } -}) +} + +/** + * Update link documents for a record - this is to be called by the record controller when a record is being changed. + * @param {EventType} eventType states what type of record change is occurring, means this can be expanded upon in the + * future quite easily (all updates go through one function). + * @param {string} instanceId The ID of the instance in which the record update is occurring. + * @param {object} record The record which is changing, e.g. created, updated or deleted. + * @param {string} modelId The ID of the of the model which is being updated. + * @param {object|null} model If the model has already been retrieved this can be used to reduce database gets. + * @returns {Promise} When the update is complete this will respond successfully. + */ +module.exports.updateLinksForRecord = async ({ + eventType, + instanceId, + record, + modelId, + model, +}) => { + // can't operate without these properties + if (instanceId == null || modelId == null) { + return null + } + let linkController = new LinkController({ + instanceId, + modelId, + model, + record, + }) + if (!(await linkController.doesModelHaveLinkedFields())) { + return null + } + switch (eventType) { + case EventType.RECORD_SAVE: + case EventType.RECORD_UPDATE: + return await linkController.recordSaved() + case EventType.RECORD_DELETE: + return await linkController.recordDeleted() + default: + throw "Type of event is not known, linked record handler requires update." + } +} /** * Gets the linking documents, not the linked documents themselves. @@ -104,6 +132,10 @@ module.exports.getLinkDocuments = async ({ params = { startKey: [modelId, 1], endKey: [modelId, 1, {}] } } params.include_docs = !!includeDoc - const response = await db.query("database/by_link", params) - return response.rows.map(row => row.doc) + try { + const response = await db.query("database/by_link", params) + return response.rows.map(row => row.doc) + } catch (err) { + console.error(err) + } }