Update to make sure all linked record updates occur in sync with the API call.

This commit is contained in:
mike12345567 2020-09-29 11:55:18 +01:00
parent af31dfc978
commit bfe2bb5a23
5 changed files with 165 additions and 110 deletions

View File

@ -1,5 +1,6 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const newid = require("../../db/newid") const newid = require("../../db/newid")
const { EventType, updateLinksForModel } = require("../../db/linkedRecords")
exports.fetch = async function(ctx) { exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId) const db = new CouchDB(ctx.user.instanceId)
@ -53,21 +54,6 @@ exports.save = async function(ctx) {
const result = await db.post(modelToSave) const result = await db.post(modelToSave)
modelToSave._rev = result.rev 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") const designDoc = await db.get("_design/database")
designDoc.views = { designDoc.views = {
...designDoc.views, ...designDoc.views,
@ -80,6 +66,12 @@ exports.save = async function(ctx) {
}, },
} }
await db.put(designDoc) await db.put(designDoc)
// update linked records
await updateLinksForModel({
instanceId,
eventType: EventType.MODEL_SAVE,
model: modelToSave,
})
// syntactic sugar for event emission // syntactic sugar for event emission
modelToSave.modelId = modelToSave._id modelToSave.modelId = modelToSave._id
@ -106,20 +98,16 @@ exports.destroy = async function(ctx) {
records.rows.map(record => ({ _id: record.id, _deleted: true })) 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 // delete the "all" view
const designDoc = await db.get("_design/database") const designDoc = await db.get("_design/database")
delete designDoc.views[modelViewId] delete designDoc.views[modelViewId]
await db.put(designDoc) await db.put(designDoc)
// update linked records
await updateLinksForModel({
instanceId,
eventType: EventType.MODEL_DELETE,
model: modelToDelete,
})
// syntactic sugar for event emission // syntactic sugar for event emission
modelToDelete.modelId = modelToDelete._id modelToDelete.modelId = modelToDelete._id

View File

@ -1,6 +1,11 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const validateJs = require("validate.js") const validateJs = require("validate.js")
const newid = require("../../db/newid") const newid = require("../../db/newid")
const {
EventType,
updateLinksForRecord,
getLinkDocuments,
} = require("../../db/linkedRecords")
validateJs.extend(validateJs.validators.datetime, { validateJs.extend(validateJs.validators.datetime, {
parse: function(value) { parse: function(value) {
@ -41,6 +46,14 @@ exports.patch = async function(ctx) {
const response = await db.put(record) const response = await db.put(record)
record._rev = response.rev record._rev = response.rev
record.type = "record" record.type = "record"
await updateLinksForRecord({
instanceId,
eventType: EventType.RECORD_UPDATE,
record,
modelId: record.modelId,
model,
})
ctx.eventEmitter && ctx.eventEmitter &&
ctx.eventEmitter.emitRecord(`record:update`, instanceId, record, model) ctx.eventEmitter.emitRecord(`record:update`, instanceId, record, model)
ctx.body = record ctx.body = record
@ -89,29 +102,13 @@ exports.save = async function(ctx) {
record.type = "record" record.type = "record"
const response = await db.post(record) const response = await db.post(record)
record._rev = response.rev record._rev = response.rev
await updateLinksForRecord({
// create links in other tables instanceId,
for (let key in record) { eventType: EventType.RECORD_SAVE,
if (model.schema[key] && model.schema[key].type === "link") { record,
const linked = await db.allDocs({ modelId: record.modelId,
include_docs: true, model,
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)
}
}
ctx.eventEmitter && ctx.eventEmitter &&
ctx.eventEmitter.emitRecord(`record:save`, instanceId, record, model) 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.body = await db.remove(ctx.params.recordId, ctx.params.revId)
ctx.status = 200 ctx.status = 200
await updateLinksForRecord({
instanceId,
eventType: EventType.RECORD_DELETE,
record,
modelId: record.modelId,
})
// for automations include the record that was deleted // for automations include the record that was deleted
ctx.record = record ctx.record = record
ctx.eventEmitter && ctx.eventEmitter &&
@ -201,7 +205,7 @@ async function validate({ instanceId, modelId, record, model }) {
model = await db.get(modelId) model = await db.get(modelId)
} }
const errors = {} const errors = {}
for (let fieldName in model.schema) { for (let fieldName of Object.keys(model.schema)) {
const res = validateJs.single( const res = validateJs.single(
record[fieldName], record[fieldName],
model.schema[fieldName].constraints model.schema[fieldName].constraints
@ -210,3 +214,28 @@ async function validate({ instanceId, modelId, record, model }) {
} }
return { valid: Object.keys(errors).length === 0, errors } 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 }
}

View File

@ -45,7 +45,7 @@ describe("/models", () => {
.expect(200) .expect(200)
.end(async (err, res) => { .end(async (err, res) => {
expect(res.res.statusMessage).toEqual("Model TestModel saved successfully."); expect(res.res.statusMessage).toEqual("Model TestModel saved successfully.");
expect(res.body.name).toEqual("TestModel"); expect(res.body.name).toEqual("TestModel");
done(); done();
}); });
}) })
@ -201,10 +201,10 @@ describe("/models", () => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.end(async (_, res) => { .end(async (_, res) => {
expect(res.res.statusMessage).toEqual(`Model ${testModel._id} deleted.`); expect(res.res.statusMessage).toEqual(`Model ${testModel._id} deleted.`);
const dependentModel = await getDocument(instance._id, linkedModel._id) const dependentModel = await getDocument(instance._id, linkedModel._id)
expect(dependentModel.schema.TestModel).not.toBeDefined(); expect(dependentModel.schema.TestModel).not.toBeDefined();
done(); done();
}); });
}) })

View File

@ -21,6 +21,10 @@ function LinkDocument(
fieldName2, fieldName2,
recordId2 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 = { this.doc1 = {
modelId: modelId1, modelId: modelId1,
fieldName: fieldName1, fieldName: fieldName1,
@ -34,18 +38,12 @@ function LinkDocument(
} }
class LinkController { class LinkController {
/** constructor({ instanceId, modelId, record, model }) {
* 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) {
this._instanceId = instanceId this._instanceId = instanceId
this._db = new CouchDB(instanceId) this._db = new CouchDB(instanceId)
this._modelId = eventData.modelId this._modelId = modelId
this._record = eventData.record this._record = record
this._model = eventData.model this._model = model
} }
/** /**
@ -106,9 +104,8 @@ class LinkController {
const field = model.schema[fieldName] const field = model.schema[fieldName]
if (field.type === "link") { if (field.type === "link") {
// get link docs to compare against // get link docs to compare against
let currentLinkIds = await this.getLinkDocs(fieldName, record._id).map( let linkDocs = await this.getLinkDocs(fieldName, record._id)
doc => doc._id let currentLinkIds = linkDocs.map(doc => doc._id)
)
let toLinkIds = record[fieldName] let toLinkIds = record[fieldName]
for (let linkId of toLinkIds) { for (let linkId of toLinkIds) {
if (currentLinkIds.indexOf(linkId) === -1) { if (currentLinkIds.indexOf(linkId) === -1) {
@ -144,7 +141,11 @@ class LinkController {
async recordDeleted() { async recordDeleted() {
const record = this._record const record = this._record
// get link docs to compare against // 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 { return {
...doc, ...doc,
_deleted: true, _deleted: true,
@ -196,8 +197,13 @@ class LinkController {
await this._db.put(linkedModel) 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 // get link docs for this model and configure for deletion
let toDelete = await this.getLinkDocs().map(doc => { let toDelete = linkDocs.map(doc => {
return { return {
...doc, ...doc,
_deleted: true, _deleted: true,

View File

@ -1,5 +1,3 @@
const emitter = require("../../events")
const InMemoryQueue = require("../../utilities/queue/inMemoryQueue")
const LinkController = require("./LinkController") const LinkController = require("./LinkController")
const CouchDB = require("../index") const CouchDB = require("../index")
@ -16,55 +14,85 @@ const EventType = {
MODEL_DELETE: "model:delete", MODEL_DELETE: "model:delete",
} }
const linkedRecordQueue = new InMemoryQueue("linkedRecordQueue") module.exports.EventType = EventType
function createEmitterCallback(eventName) { /**
emitter.on(eventName, function(event) { * Update link documents for a model - this is to be called by the model controller when a model is being changed.
if (!event || !event.record || !event.record.modelId) { * @param {EventType} eventType states what type of model change is occurring, means this can be expanded upon in the
return * future quite easily (all updates go through one function).
} * @param {string} instanceId The ID of the instance in which the model change is occurring.
linkedRecordQueue.add({ * @param {object} model The model which is changing, whether it is being deleted, created or updated.
type: eventName, * @returns {Promise<null>} When the update is complete this will respond successfully.
event, */
}) module.exports.updateLinksForModel = async ({
}) eventType,
} instanceId,
model,
for (let typeKey of Object.keys(EventType)) { }) => {
createEmitterCallback(EventType[typeKey])
}
linkedRecordQueue.process(async job => {
let data = job.data
// can't operate without these properties // can't operate without these properties
if (data.instanceId == null || data.modelId == null) { if (instanceId == null || model == null) {
return return null
} }
// link controller exists to help manage state, the operation let linkController = new LinkController({
// of updating links is a particularly stateful task instanceId,
let linkController = new LinkController(data.instanceId, data) modelId: model._id,
// model doesn't have links, can stop here model,
})
if (!(await linkController.doesModelHaveLinkedFields())) { if (!(await linkController.doesModelHaveLinkedFields())) {
return return null
} }
// carry out the logic at a top level so that we can handle switch (eventType) {
// 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
case EventType.MODEL_SAVE: case EventType.MODEL_SAVE:
await linkController.modelSaved() await linkController.modelSaved()
break break
case EventType.MODEL_DELETE: case EventType.MODEL_DELETE:
await linkController.modelDeleted() await linkController.modelDeleted()
break 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<null>} 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. * 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 = { startKey: [modelId, 1], endKey: [modelId, 1, {}] }
} }
params.include_docs = !!includeDoc params.include_docs = !!includeDoc
const response = await db.query("database/by_link", params) try {
return response.rows.map(row => row.doc) const response = await db.query("database/by_link", params)
return response.rows.map(row => row.doc)
} catch (err) {
console.error(err)
}
} }