Merge branch 'linked-records' of github.com:Budibase/budibase into linked-records

This commit is contained in:
Andrew Kingston 2020-10-01 07:22:24 +01:00
commit 14457e5801
4 changed files with 200 additions and 103 deletions

View File

@ -19,12 +19,18 @@ exports.find = async function(ctx) {
exports.save = async function(ctx) { exports.save = async function(ctx) {
const instanceId = ctx.user.instanceId const instanceId = ctx.user.instanceId
const db = new CouchDB(instanceId) const db = new CouchDB(instanceId)
const oldModelId = ctx.request.body._id
const modelToSave = { const modelToSave = {
type: "model", type: "model",
_id: newid(), _id: newid(),
views: {}, views: {},
...ctx.request.body, ...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 // rename record fields when table column is renamed
const { _rename } = modelToSave const { _rename } = modelToSave
@ -70,8 +76,11 @@ exports.save = async function(ctx) {
// update linked records // update linked records
await linkRecords.updateLinks({ await linkRecords.updateLinks({
instanceId, instanceId,
eventType: linkRecords.EventType.MODEL_SAVE, eventType: oldModel
? linkRecords.EventType.MODEL_UPDATED
: linkRecords.EventType.MODEL_SAVE,
model: modelToSave, model: modelToSave,
oldModel: oldModel,
}) })
await db.put(designDoc) await db.put(designDoc)

View File

@ -1,5 +1,5 @@
const CouchDB = require("../index") 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 * 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. * 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. * 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<boolean>} True if there are any linked fields, otherwise it will return * @returns {Promise<boolean>} True if there are any linked fields, otherwise it will return
* false. * false.
*/ */
async doesModelHaveLinkedFields() { async doesModelHaveLinkedFields(model = null) {
const model = await this.model() if (model == null) {
model = await this.model()
}
for (let fieldName of Object.keys(model.schema)) { for (let fieldName of Object.keys(model.schema)) {
const { type } = model.schema[fieldName] const { type } = model.schema[fieldName]
if (type === "link") { if (type === "link") {
@ -79,12 +82,23 @@ class LinkController {
/** /**
* Utility function for main getLinkDocuments function - refer to it for functionality. * Utility function for main getLinkDocuments function - refer to it for functionality.
*/ */
getLinkDocs(recordId = null) { getRecordLinkDocs(recordId, includeDocs = IncludeDocs.EXCLUDE) {
return linkedRecords.getLinkDocuments({ return getLinkDocuments({
instanceId: this._instanceId, instanceId: this._instanceId,
modelId: this._modelId, modelId: this._modelId,
recordId, 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 record = this._record
const operations = [] const operations = []
// get link docs to compare against // 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)) { for (let fieldName of Object.keys(model.schema)) {
// get the links this record wants to make // get the links this record wants to make
const recordField = record[fieldName] const recordField = record[fieldName]
@ -155,15 +169,16 @@ class LinkController {
async recordDeleted() { async recordDeleted() {
const record = this._record const record = this._record
// need to get the full link docs to be be able to delete it // need to get the full link docs to be be able to delete it
const linkDocIds = await this.getLinkDocs(record._id).map( const linkDocs = await this.getRecordLinkDocs(
linkVal => linkVal.id record._id,
IncludeDocs.INCLUDE
) )
if (linkDocIds.length === 0) { if (linkDocs.length === 0) {
return null return null
} }
const toDelete = linkDocIds.map(id => { const toDelete = linkDocs.map(doc => {
return { return {
_id: id, ...doc,
_deleted: true, _deleted: true,
} }
}) })
@ -171,6 +186,36 @@ class LinkController {
return record 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<void>} 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 * When a model is saved this will carry out the necessary operations to make sure
* any linked models are notified and updated correctly. * any linked models are notified and updated correctly.
@ -198,6 +243,26 @@ class LinkController {
return model 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<Object>} 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 * 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 * 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 // need to get the full link docs to delete them
const linkDocIds = await this.getLinkDocs().map(linkVal => linkVal.id) const linkDocs = await this.getModelLinkDocs(IncludeDocs.INCLUDE)
if (linkDocIds.length === 0) { if (linkDocs.length === 0) {
return null return null
} }
// get link docs for this model and configure for deletion // get link docs for this model and configure for deletion
const toDelete = linkDocIds.map(id => { const toDelete = linkDocs.map(doc => {
return { return {
_id: id, ...doc,
_deleted: true, _deleted: true,
} }
}) })

View File

@ -1,6 +1,5 @@
const LinkController = require("./LinkController") const LinkController = require("./LinkController")
const CouchDB = require("../index") const { IncludeDocs, getLinkDocuments, createLinkView } = require("./linkUtils")
const Sentry = require("@sentry/node")
/** /**
* This functionality makes sure that when records with links are created, updated or deleted they are processed * 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_UPDATE: "record:update",
RECORD_DELETE: "record:delete", RECORD_DELETE: "record:delete",
MODEL_SAVE: "model:save", MODEL_SAVE: "model:save",
MODEL_UPDATED: "model:updated",
MODEL_DELETE: "model:delete", MODEL_DELETE: "model:delete",
} }
exports.EventType = EventType exports.EventType = EventType
// re-export utils here for ease of use
/** exports.IncludeDocs = IncludeDocs
* Creates the link view for the instance, this will overwrite the existing one, but this should only exports.getLinkDocuments = getLinkDocuments
* be called if it is found that the view does not exist. exports.createLinkView = createLinkView
* @param {string} instanceId The instance to which the view should be added.
* @returns {Promise<void>} 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)
}
/** /**
* Update link documents for a record or model - this is to be called by the API controller when a change is occurring. * 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). * 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} 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 {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} 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<object>} When the update is complete this will respond successfully. Returns the record for * @returns {Promise<object>} When the update is complete this will respond successfully. Returns the record for
* record operations and the model for model operations. * record operations and the model for model operations.
*/ */
@ -67,6 +39,7 @@ exports.updateLinks = async ({
record, record,
modelId, modelId,
model, model,
oldModel,
}) => { }) => {
// make sure model ID is set // make sure model ID is set
if (model != null) { if (model != null) {
@ -78,7 +51,11 @@ exports.updateLinks = async ({
model, model,
record, record,
}) })
if (!(await linkController.doesModelHaveLinkedFields())) { if (
!(await linkController.doesModelHaveLinkedFields()) &&
(oldModel == null ||
!(await linkController.doesModelHaveLinkedFields(oldModel)))
) {
return record return record
} }
switch (eventType) { switch (eventType) {
@ -89,6 +66,8 @@ exports.updateLinks = async ({
return await linkController.recordDeleted() return await linkController.recordDeleted()
case EventType.MODEL_SAVE: case EventType.MODEL_SAVE:
return await linkController.modelSaved() return await linkController.modelSaved()
case EventType.MODEL_UPDATED:
return await linkController.modelUpdated(oldModel)
case EventType.MODEL_DELETE: case EventType.MODEL_DELETE:
return await linkController.modelDeleted() return await linkController.modelDeleted()
default: default:
@ -114,11 +93,11 @@ exports.attachLinkInfo = async (instanceId, records) => {
// start by getting all the link values for performance reasons // start by getting all the link values for performance reasons
let responses = await Promise.all( let responses = await Promise.all(
records.map(record => records.map(record =>
exports.getLinkDocuments({ getLinkDocuments({
instanceId, instanceId,
modelId: record.modelId, modelId: record.modelId,
recordId: record._id, 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 // otherwise return the first element as there was only one input
return wasArray ? records : records[0] 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<object[]>} 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)
}
}
}

View File

@ -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<void>} 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<object[]>} 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)
}
}
}