diff --git a/packages/server/src/api/controllers/instance.js b/packages/server/src/api/controllers/instance.js index c74befcc19..409c0514a3 100644 --- a/packages/server/src/api/controllers/instance.js +++ b/packages/server/src/api/controllers/instance.js @@ -1,6 +1,7 @@ const CouchDB = require("../../db") const client = require("../../db/clientDb") const newid = require("../../db/newid") +const { createLinkView } = require("../../db/linkedRecords") exports.create = async function(ctx) { const instanceName = ctx.request.body.name @@ -33,22 +34,6 @@ exports.create = async function(ctx) { emit([doc.type], doc._id) }.toString(), }, - by_link: { - map: function(doc) { - if (doc.type === "link") { - let doc1 = doc.doc1 - let doc2 = doc.doc2 - emit([doc1.modelId, 1, doc1.fieldName, doc1.recordId], { - _id: doc2.recordId, - }) - emit([doc2.modelId, 1, doc2.fieldName, doc2.recordId], { - _id: doc1.recordId, - }) - emit([doc1.modelId, 2, doc1.recordId], { _id: doc2.recordId }) - emit([doc2.modelId, 2, doc2.recordId], { _id: doc1.recordId }) - } - }.toString(), - }, by_automation_trigger: { map: function(doc) { if (doc.type === "automation") { @@ -61,6 +46,8 @@ exports.create = async function(ctx) { }, }, }) + // add view for linked records + await createLinkView(instanceId) // Add the new instance under the app clientDB const clientDb = new CouchDB(client.name(clientId)) diff --git a/packages/server/src/api/controllers/model.js b/packages/server/src/api/controllers/model.js index 990a36541c..909c7b631e 100644 --- a/packages/server/src/api/controllers/model.js +++ b/packages/server/src/api/controllers/model.js @@ -65,13 +65,13 @@ exports.save = async function(ctx) { }`, }, } - await db.put(designDoc) // update linked records await updateLinksForModel({ instanceId, eventType: EventType.MODEL_SAVE, model: modelToSave, }) + await db.put(designDoc) // syntactic sugar for event emission modelToSave.modelId = modelToSave._id @@ -98,16 +98,16 @@ exports.destroy = async function(ctx) { records.rows.map(record => ({ _id: record.id, _deleted: true })) ) - // 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, }) + // delete the "all" view + const designDoc = await db.get("_design/database") + delete designDoc.views[modelViewId] + await db.put(designDoc) // 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 11fbc811b9..a27746b563 100644 --- a/packages/server/src/api/controllers/record.js +++ b/packages/server/src/api/controllers/record.js @@ -5,6 +5,8 @@ const { EventType, updateLinksForRecord, getLinkDocuments, + attachLinkInfoToRecord, + attachLinkInfoToRecords, } = require("../../db/linkedRecords") validateJs.extend(validateJs.validators.datetime, { @@ -20,11 +22,11 @@ validateJs.extend(validateJs.validators.datetime, { exports.patch = async function(ctx) { const instanceId = ctx.user.instanceId const db = new CouchDB(instanceId) - const record = await db.get(ctx.params.id) const model = await db.get(record.modelId) + let record = await db.get(ctx.params.id) const patchfields = ctx.request.body - for (let key in patchfields) { + for (let key of Object.keys(patchfields)) { if (!model.schema[key]) continue record[key] = patchfields[key] } @@ -43,16 +45,17 @@ exports.patch = async function(ctx) { return } - const response = await db.put(record) - record._rev = response.rev - record.type = "record" - await updateLinksForRecord({ + // returned record is cleaned and prepared for writing to DB + record = await updateLinksForRecord({ instanceId, eventType: EventType.RECORD_UPDATE, record, modelId: record.modelId, model, }) + const response = await db.put(record) + record._rev = response.rev + record.type = "record" ctx.eventEmitter && ctx.eventEmitter.emitRecord(`record:update`, instanceId, record, model) @@ -64,7 +67,7 @@ exports.patch = async function(ctx) { exports.save = async function(ctx) { const instanceId = ctx.user.instanceId const db = new CouchDB(instanceId) - const record = ctx.request.body + let record = ctx.request.body record.modelId = ctx.params.modelId if (!record._rev && !record._id) { @@ -99,16 +102,16 @@ exports.save = async function(ctx) { return } - record.type = "record" - const response = await db.post(record) - record._rev = response.rev - await updateLinksForRecord({ + record = await updateLinksForRecord({ instanceId, eventType: EventType.RECORD_SAVE, record, modelId: record.modelId, model, }) + record.type = "record" + const response = await db.post(record) + record._rev = response.rev ctx.eventEmitter && ctx.eventEmitter.emitRecord(`record:save`, instanceId, record, model) @@ -118,7 +121,8 @@ exports.save = async function(ctx) { } exports.fetchView = async function(ctx) { - const db = new CouchDB(ctx.user.instanceId) + const instanceId = ctx.user.instanceId + const db = new CouchDB(instanceId) const { stats, group, field } = ctx.query const response = await db.query(`database/${ctx.params.viewName}`, { include_docs: !stats, @@ -136,52 +140,61 @@ exports.fetchView = async function(ctx) { response.rows = response.rows.map(row => row.doc) } - ctx.body = response.rows + ctx.body = await attachLinkInfoToRecords(instanceId, response.rows) } exports.fetchModelRecords = async function(ctx) { - const db = new CouchDB(ctx.user.instanceId) + const instanceId = ctx.user.instanceId + const db = new CouchDB(instanceId) const response = await db.query(`database/all_${ctx.params.modelId}`, { include_docs: true, }) - ctx.body = response.rows.map(row => row.doc) + ctx.body = await attachLinkInfoToRecords( + instanceId, + response.rows.map(row => row.doc) + ) } exports.search = async function(ctx) { - const db = new CouchDB(ctx.user.instanceId) + const instanceId = ctx.user.instanceId + const db = new CouchDB(instanceId) const response = await db.allDocs({ include_docs: true, ...ctx.request.body, }) - ctx.body = response.rows.map(row => row.doc) + ctx.body = await attachLinkInfoToRecords( + instanceId, + response.rows.map(row => row.doc) + ) } exports.find = async function(ctx) { - const db = new CouchDB(ctx.user.instanceId) + const instanceId = ctx.user.instanceId + const db = new CouchDB(instanceId) const record = await db.get(ctx.params.recordId) if (record.modelId !== ctx.params.modelId) { ctx.throw(400, "Supplied modelId does not match the records modelId") return } - ctx.body = record + ctx.body = await attachLinkInfoToRecord(instanceId, record) } exports.destroy = async function(ctx) { const instanceId = ctx.user.instanceId - const db = new CouchDB() + const db = new CouchDB(instanceId) const record = await db.get(ctx.params.recordId) if (record.modelId !== ctx.params.modelId) { ctx.throw(400, "Supplied modelId doesn't match the record's modelId") return } - 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, }) + ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId) + ctx.status = 200 // for automations include the record that was deleted ctx.record = record @@ -217,6 +230,7 @@ async function validate({ instanceId, modelId, record, model }) { exports.fetchLinkedRecords = async function(ctx) { const instanceId = ctx.user.instanceId + const db = new CouchDB(instanceId) const modelId = ctx.params.modelId const fieldName = ctx.params.fieldName const recordId = ctx.params.recordId @@ -229,13 +243,18 @@ exports.fetchLinkedRecords = async function(ctx) { } return } - let records = await getLinkDocuments({ + // get the link docs + const linkDocIds = await getLinkDocuments({ instanceId, modelId, fieldName, recordId, - includeDoc: true, }) + // now get the docs from the all docs index + const response = await db.query(`database/_all_docs`, { + include_docs: true, + keys: linkDocIds, + }) + ctx.body = response.rows.map(row => row.doc) ctx.status = 200 - ctx.body = { records: records } } diff --git a/packages/server/src/db/linkedRecords/LinkController.js b/packages/server/src/db/linkedRecords/LinkController.js index 983155e4b9..38ec97cc7c 100644 --- a/packages/server/src/db/linkedRecords/LinkController.js +++ b/packages/server/src/db/linkedRecords/LinkController.js @@ -67,7 +67,7 @@ class LinkController { */ async doesModelHaveLinkedFields() { const model = await this.model() - for (const fieldName of Object.keys(model.schema)) { + for (let fieldName of Object.keys(model.schema)) { const { type } = model.schema[fieldName] if (type === "link") { return true @@ -79,12 +79,13 @@ class LinkController { /** * Utility function for main getLinkDocuments function - refer to it for functionality. */ - getLinkDocs(fieldName, recordId) { + getLinkDocs(includeDocs, fieldName = null, recordId = null) { return linkedRecords.getLinkDocuments({ instanceId: this._instanceId, modelId: this._modelId, fieldName, recordId, + includeDocs, }) } @@ -93,22 +94,23 @@ class LinkController { /** * When a record is saved this will carry out the necessary operations to make sure * the link has been created/updated. - * @returns {Promise} The operation has been completed and the link documents should now - * be accurate. + * @returns {Promise} returns the record that has been cleaned and prepared to be written to the DB - links + * have also been created. */ async recordSaved() { const model = await this.model() const record = this._record - let operations = [] + const operations = [] for (let fieldName of Object.keys(model.schema)) { const field = model.schema[fieldName] if (field.type === "link") { // get link docs to compare against - let linkDocs = await this.getLinkDocs(fieldName, record._id) - let currentLinkIds = linkDocs.map(doc => doc._id) - let toLinkIds = record[fieldName] + const linkDocIds = await this.getLinkDocs(false, fieldName, record._id) + // get the links this record wants to make + const toLinkIds = record[fieldName] + // iterate through them and find any which don't exist, create them for (let linkId of toLinkIds) { - if (currentLinkIds.indexOf(linkId) === -1) { + if (linkDocIds.indexOf(linkId) === -1) { operations.push( new LinkDocument( model._id, @@ -120,96 +122,104 @@ class LinkController { ) ) } - const toDeleteIds = currentLinkIds.filter( + // work out any that need to be deleted + const toDeleteIds = linkDocIds.filter( id => toLinkIds.indexOf(id) === -1 ) operations.concat( toDeleteIds.map(id => ({ _id: id, _deleted: true })) ) } + // remove the field from the record, shouldn't store it + delete record[fieldName] } } await this._db.bulkDocs(operations) + return record } /** * When a record is deleted this will carry out the necessary operations to make sure * any links that existed have been removed. - * @returns {Promise} The operation has been completed and the link documents should now - * be accurate. + * @returns {Promise} The operation has been completed and the link documents should now + * be accurate. This also returns the record that was deleted. */ async recordDeleted() { const record = this._record - // get link docs to compare against - let linkDocs = await this.getLinkDocs(null, record._id) + // need to get the full link docs to be be able to delete it + const linkDocs = await this.getLinkDocs(true, null, record._id) if (linkDocs.length === 0) { return null } - let toDelete = linkDocs.map(doc => { + const toDelete = linkDocs.map(doc => { return { ...doc, _deleted: true, } }) await this._db.bulkDocs(toDelete) + return record } /** * When a model is saved this will carry out the necessary operations to make sure * any linked models are notified and updated correctly. - * @returns {Promise} The operation has been completed and the link documents should now - * be accurate. + * @returns {Promise} The operation has been completed and the link documents should now + * be accurate. Also returns the model that was operated on. */ async modelSaved() { const model = await this.model() const schema = model.schema - for (const fieldName of Object.keys(schema)) { + for (let fieldName of Object.keys(schema)) { const field = schema[fieldName] if (field.type === "link") { // create the link field in the other model const linkedModel = await this._db.get(field.modelId) linkedModel.schema[field.fieldName] = { - name: model.name, + name: field.fieldName, type: "link", + // these are the props of the table that initiated the link modelId: model._id, fieldName: fieldName, } await this._db.put(linkedModel) } } + return model } /** * 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 * now stale linking documents. - * @returns {Promise} The operation has been completed and the link documents should now - * be accurate. + * @returns {Promise} The operation has been completed and the link documents should now + * be accurate. Also returns the model that was operated on. */ async modelDeleted() { const model = await this.model() const schema = model.schema - for (const fieldName of Object.keys(schema)) { - let field = schema[fieldName] + for (let fieldName of Object.keys(schema)) { + const field = schema[fieldName] if (field.type === "link") { const linkedModel = await this._db.get(field.modelId) delete linkedModel.schema[model.name] await this._db.put(linkedModel) } } - // get link docs to compare against - let linkDocs = await this.getLinkDocs() + // need to get the full link docs to delete them + const linkDocs = await this.getLinkDocs(true) if (linkDocs.length === 0) { return null } // get link docs for this model and configure for deletion - let toDelete = linkDocs.map(doc => { + const toDelete = linkDocs.map(doc => { return { ...doc, _deleted: true, } }) await this._db.bulkDocs(toDelete) + return model } } diff --git a/packages/server/src/db/linkedRecords/index.js b/packages/server/src/db/linkedRecords/index.js index a4025ea90c..6ecd5b5a2c 100644 --- a/packages/server/src/db/linkedRecords/index.js +++ b/packages/server/src/db/linkedRecords/index.js @@ -14,7 +14,7 @@ const EventType = { MODEL_DELETE: "model:delete", } -module.exports.EventType = EventType +exports.EventType = EventType /** * Update link documents for a model - this is to be called by the model controller when a model is being changed. @@ -22,16 +22,13 @@ module.exports.EventType = EventType * 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. + * @returns {Promise} When the update is complete this will respond successfully. Returns the model that was + * operated upon. */ -module.exports.updateLinksForModel = async ({ - eventType, - instanceId, - model, -}) => { +exports.updateLinksForModel = async ({ eventType, instanceId, model }) => { // can't operate without these properties if (instanceId == null || model == null) { - return null + return model } let linkController = new LinkController({ instanceId, @@ -39,15 +36,13 @@ module.exports.updateLinksForModel = async ({ model, }) if (!(await linkController.doesModelHaveLinkedFields())) { - return null + return model } switch (eventType) { case EventType.MODEL_SAVE: - await linkController.modelSaved() - break + return await linkController.modelSaved() case EventType.MODEL_DELETE: - await linkController.modelDeleted() - break + return await linkController.modelDeleted() default: throw "Type of event is not known, linked record handler requires update." } @@ -61,9 +56,10 @@ module.exports.updateLinksForModel = async ({ * @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. + * @returns {Promise} When the update is complete this will respond successfully. Returns the record that was + * operated upon, cleaned up and prepared for writing to DB. */ -module.exports.updateLinksForRecord = async ({ +exports.updateLinksForRecord = async ({ eventType, instanceId, record, @@ -71,8 +67,8 @@ module.exports.updateLinksForRecord = async ({ model, }) => { // can't operate without these properties - if (instanceId == null || modelId == null) { - return null + if (instanceId == null || modelId == null || record == null) { + return record } let linkController = new LinkController({ instanceId, @@ -81,7 +77,7 @@ module.exports.updateLinksForRecord = async ({ record, }) if (!(await linkController.doesModelHaveLinkedFields())) { - return null + return record } switch (eventType) { case EventType.RECORD_SAVE: @@ -94,6 +90,73 @@ module.exports.updateLinksForRecord = async ({ } } +/** + * Utility function to in parallel up a list of records with link info. + * @param {string} instanceId The instance in which this record has been created. + * @param {object[]} records A list records to be updated with link info. + * @returns {Promise} The updated records (this may be the same if no links were found). + */ +exports.attachLinkInfoToRecords = async (instanceId, records) => { + let recordPromises = [] + for (let record of records) { + recordPromises.push(exports.attachLinkInfoToRecord(instanceId, record)) + } + return await Promise.all(recordPromises) +} + +/** + * Update a record with information about the links that pertain to it. + * @param {string} instanceId The instance in which this record has been created. + * @param {object} record The record itself which is to be updated with info (if applicable). + * @returns {Promise} The updated record (this may be the same if no links were found). + */ +exports.attachLinkInfoToRecord = async (instanceId, record) => { + const recordId = record._id + const modelId = record.modelId + // get all links for record, ignore fieldName for now + const linkDocs = await exports.getLinkDocuments({ + instanceId, + modelId, + recordId, + includeDocs: true, + }) + if (linkDocs == null || linkDocs.length === 0) { + return record + } + for (let linkDoc of linkDocs) { + // work out which link pertains to this record + const doc = linkDoc.doc1.recordId === recordId ? linkDoc.doc1 : linkDoc.doc2 + if (record[doc.fieldName] == null || record[doc.fieldName].count == null) { + record[doc.fieldName] = { type: "link", count: 1 } + } else { + record[doc.fieldName].count++ + } + } + return record +} + +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, 1, doc1.fieldName, doc1.recordId], doc2.recordId) + emit([doc2.modelId, 1, doc2.fieldName, doc2.recordId], doc1.recordId) + emit([doc1.modelId, 2, doc1.recordId], doc2.recordId) + emit([doc2.modelId, 2, doc2.recordId], doc1.recordId) + } + }.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. @@ -103,17 +166,17 @@ module.exports.updateLinksForRecord = async ({ * @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} includeDoc whether to include docs in the response call, this is considerably slower so only + * @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). */ -module.exports.getLinkDocuments = async ({ +exports.getLinkDocuments = async ({ instanceId, modelId, fieldName, recordId, - includeDoc, + includeDocs, }) => { const db = new CouchDB(instanceId) let params @@ -131,11 +194,16 @@ module.exports.getLinkDocuments = async ({ else { params = { startKey: [modelId, 1], endKey: [modelId, 1, {}] } } - params.include_docs = !!includeDoc + params.include_docs = !!includeDocs try { const response = await db.query("database/by_link", params) return response.rows.map(row => row.doc) } catch (err) { - console.error(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 { + console.error(err) + } } }