1
0
Fork 0
mirror of synced 2024-09-21 20:01:32 +12:00
budibase/packages/server/src/db/linkedRows/LinkController.ts

467 lines
15 KiB
TypeScript
Raw Normal View History

import { IncludeDocs, getLinkDocuments } from "./linkUtils"
import { InternalTables, getUserMetadataParams } from "../utils"
2023-01-20 22:58:59 +13:00
import { FieldTypes } from "../../constants"
2023-10-18 23:07:50 +13:00
import { context, logging } from "@budibase/backend-core"
import LinkDocument from "./LinkDocument"
import {
Database,
FieldSchema,
2023-10-12 00:36:56 +13:00
FieldType,
LinkDocumentValue,
2023-10-06 06:47:00 +13:00
RelationshipFieldMetadata,
RelationshipType,
Row,
Table,
} from "@budibase/types"
type LinkControllerOpts = {
2022-11-27 04:10:41 +13:00
tableId?: string
row?: Row
table?: Table
oldTable?: Table
}
class LinkController {
_db: Database
2022-11-27 04:10:41 +13:00
_tableId?: string
_row?: Row
_table?: Table
_oldTable?: Table
constructor({ tableId, row, table, oldTable }: LinkControllerOpts) {
this._db = context.getAppDB()
this._tableId = tableId
this._row = row
this._table = table
this._oldTable = oldTable
}
/**
* Retrieves the table, if it was not already found in the eventData.
* @returns This will return a table based on the event data, either
* if it was in the event already, or it uses the specified tableId to get it.
*/
async table() {
if (this._table == null) {
this._table =
this._table == null ? await this._db.get(this._tableId) : this._table
}
return this._table!
}
/**
* Checks if the table this was constructed with has any linking columns currently.
* If the table has not been retrieved this will retrieve it based on the eventData.
* @params table If a table that is not known to the link controller is to be tested.
* @returns True if there are any linked fields, otherwise it will return
* false.
*/
async doesTableHaveLinkedFields(table?: Table) {
if (table == null) {
table = await this.table()
}
for (let fieldName of Object.keys(table.schema)) {
const { type } = table.schema[fieldName]
if (type === FieldTypes.LINK) {
return true
}
}
return false
}
/**
* Utility function for main getLinkDocuments function - refer to it for functionality.
*/
getRowLinkDocs(rowId: string) {
return getLinkDocuments({
tableId: this._tableId,
rowId,
includeDocs: IncludeDocs.INCLUDE,
})
}
/**
* Utility function for main getLinkDocuments function - refer to it for functionality.
*/
async getTableLinkDocs() {
return (await getLinkDocuments({
tableId: this._tableId,
includeDocs: IncludeDocs.INCLUDE,
})) as LinkDocument[]
}
/**
* Makes sure the passed in table schema contains valid relationship structures.
*/
validateTable(table: Table) {
const usedAlready = []
for (let schema of Object.values(table.schema)) {
if (schema.type !== FieldTypes.LINK) {
continue
}
const unique = schema.tableId! + schema?.fieldName
if (usedAlready.indexOf(unique) !== -1) {
2021-02-26 01:10:18 +13:00
throw new Error(
"Cannot re-use the linked column name for a linked table."
)
}
usedAlready.push(unique)
}
}
/**
2021-03-17 02:24:44 +13:00
* Returns whether the two link schemas are equal (in the important parts, not a pure equality check)
*/
areLinkSchemasEqual(linkSchema1: FieldSchema, linkSchema2: FieldSchema) {
const compareFields = [
"name",
"type",
"tableId",
"fieldName",
"autocolumn",
"relationshipType",
]
for (let field of compareFields) {
// @ts-ignore
2021-03-17 02:24:44 +13:00
if (linkSchema1[field] !== linkSchema2[field]) {
return false
}
}
return true
}
/**
2021-03-17 02:24:44 +13:00
* Given the link field of this table, and the link field of the linked table, this makes sure
* the state of relationship type is accurate on both.
*/
2023-10-06 06:47:00 +13:00
handleRelationshipType(
linkerField: RelationshipFieldMetadata,
linkedField: RelationshipFieldMetadata
) {
if (
2021-03-17 02:24:44 +13:00
!linkerField.relationshipType ||
linkerField.relationshipType === RelationshipType.MANY_TO_MANY
) {
linkedField.relationshipType = RelationshipType.MANY_TO_MANY
// make sure by default all are many to many (if not specified)
linkerField.relationshipType = RelationshipType.MANY_TO_MANY
} else if (linkerField.relationshipType === RelationshipType.MANY_TO_ONE) {
// Ensure that the other side of the relationship is locked to one record
linkedField.relationshipType = RelationshipType.ONE_TO_MANY
} else if (linkerField.relationshipType === RelationshipType.ONE_TO_MANY) {
linkedField.relationshipType = RelationshipType.MANY_TO_ONE
}
2021-03-17 02:24:44 +13:00
return { linkerField, linkedField }
}
// all operations here will assume that the table
// this operation is related to has linked rows
/**
* When a row is saved this will carry out the necessary operations to make sure
* the link has been created/updated.
* @returns returns the row that has been cleaned and prepared to be written to the DB - links
* have also been created.
*/
async rowSaved() {
const table = await this.table()
const row = this._row!
const operations = []
// get link docs to compare against
const linkDocs = (await this.getRowLinkDocs(row._id!)) as LinkDocument[]
for (let fieldName of Object.keys(table.schema)) {
// get the links this row wants to make
const rowField = row[fieldName]
const field = table.schema[fieldName]
if (field.type === FieldTypes.LINK && rowField != null) {
// check which links actual pertain to the update in this row
const thisFieldLinkDocs = linkDocs.filter(
2021-05-04 22:32:22 +12:00
linkDoc =>
linkDoc.doc1.fieldName === fieldName ||
linkDoc.doc2.fieldName === fieldName
)
2021-05-04 22:32:22 +12:00
const linkDocIds = thisFieldLinkDocs.map(linkDoc => {
return linkDoc.doc1.rowId === row._id
? linkDoc.doc2.rowId
: linkDoc.doc1.rowId
})
2021-02-26 00:55:23 +13:00
// if 1:N, ensure that this ID is not already attached to another record
2023-07-18 21:41:51 +12:00
const linkedTable = await this._db.get<Table>(field.tableId)
2023-10-12 00:36:56 +13:00
const linkedSchema = linkedTable.schema[field.fieldName]
2021-02-26 00:55:23 +13:00
// We need to map the global users to metadata in each app for relationships
if (field.tableId === InternalTables.USER_METADATA) {
const users = await this._db.allDocs(getUserMetadataParams(null, {}))
2021-08-12 07:34:45 +12:00
const metadataRequired = rowField.filter(
(userId: string) => !users.rows.some(user => user.id === userId)
2021-08-12 07:34:45 +12:00
)
// ensure non-existing user metadata is created in the app DB
2021-08-12 07:34:45 +12:00
await this._db.bulkDocs(
metadataRequired.map((userId: string) => ({ _id: userId }))
2021-08-12 07:34:45 +12:00
)
}
// iterate through the link IDs in the row field, see if any don't exist already
for (let linkId of rowField) {
2023-10-12 00:36:56 +13:00
if (
linkedSchema?.type === FieldType.LINK &&
linkedSchema?.relationshipType === RelationshipType.ONE_TO_MANY
) {
let links = (
(await getLinkDocuments({
tableId: field.tableId,
rowId: linkId,
includeDocs: IncludeDocs.EXCLUDE,
})) as LinkDocumentValue[]
2021-06-20 21:55:12 +12:00
).filter(
link =>
link.id !== row._id && link.fieldName === linkedSchema.name
)
2021-02-26 00:55:23 +13:00
// The 1 side of 1:N is already related to something else
// You must remove the existing relationship
if (links.length > 0) {
throw new Error(
`1:N Relationship Error: Record already linked to another.`
)
}
}
if (linkId && linkId !== "" && linkDocIds.indexOf(linkId) === -1) {
// first check the doc we're linking to exists
try {
await this._db.get(linkId)
} catch (err) {
// skip links that don't exist
continue
}
operations.push(
new LinkDocument(
table._id!,
fieldName,
row._id!,
field.tableId!,
field.fieldName!,
linkId
)
)
}
}
// find the docs that need to be deleted
let toDeleteDocs = thisFieldLinkDocs
2021-05-04 22:32:22 +12:00
.filter(doc => {
let correctDoc
if (
doc.doc1.tableId === table._id! &&
doc.doc1.fieldName === fieldName
) {
correctDoc = doc.doc2
} else if (
doc.doc2.tableId === table._id! &&
doc.doc2.fieldName === fieldName
) {
correctDoc = doc.doc1
}
return correctDoc && rowField.indexOf(correctDoc.rowId) === -1
})
2021-05-04 22:32:22 +12:00
.map(doc => {
return { ...doc, _deleted: true }
})
// now add the docs to be deleted to the bulk operation
operations.push(...toDeleteDocs)
// remove the field from this row, link doc will be added to row on way out
delete row[fieldName]
}
}
await this._db.bulkDocs(operations)
return row
}
/**
* When a row is deleted this will carry out the necessary operations to make sure
* any links that existed have been removed.
* @returns The operation has been completed and the link documents should now
* be accurate. This also returns the row that was deleted.
*/
async rowDeleted() {
const row = this._row!
// need to get the full link docs to be be able to delete it
const linkDocs = await this.getRowLinkDocs(row._id!)
if (linkDocs.length === 0) {
return null
}
2021-05-04 22:32:22 +12:00
const toDelete = linkDocs.map(doc => {
return {
...doc,
_deleted: true,
}
})
await this._db.bulkDocs(toDelete)
return row
}
/**
* Remove a field from a table as well as any linked rows that pertained to it.
* @param fieldName The field to be removed from the table.
* @returns The table has now been updated.
*/
async removeFieldFromTable(fieldName: string) {
let oldTable = this._oldTable
2023-10-06 06:47:00 +13:00
let field = oldTable?.schema[fieldName] as RelationshipFieldMetadata
const linkDocs = await this.getTableLinkDocs()
2021-05-04 22:32:22 +12:00
let toDelete = linkDocs.filter(linkDoc => {
let correctFieldName =
linkDoc.doc1.tableId === oldTable?._id
? linkDoc.doc1.fieldName
: linkDoc.doc2.fieldName
return correctFieldName === fieldName
})
await this._db.bulkDocs(
2021-05-04 22:32:22 +12:00
toDelete.map(doc => {
return {
...doc,
_deleted: true,
}
})
)
try {
// remove schema from other table, if it exists
let linkedTable = await this._db.get<Table>(field.tableId)
if (field.fieldName) {
delete linkedTable.schema[field.fieldName]
}
await this._db.put(linkedTable)
} catch (error: any) {
// ignore missing to ensure broken relationship columns can be deleted
if (error.statusCode !== 404) {
throw error
}
}
}
/**
* When a table is saved this will carry out the necessary operations to make sure
* any linked tables are notified and updated correctly.
* @returns The operation has been completed and the link documents should now
* be accurate. Also returns the table that was operated on.
*/
async tableSaved() {
const table = await this.table()
// validate the table first
this.validateTable(table)
const schema = table.schema
for (let fieldName of Object.keys(schema)) {
const field = schema[fieldName]
if (field.type === FieldTypes.LINK && field.fieldName) {
// handle this in a separate try catch, want
// the put to bubble up as an error, if can't update
// table for some reason
let linkedTable
try {
2023-07-18 21:41:51 +12:00
linkedTable = await this._db.get<Table>(field.tableId)
} catch (err) {
/* istanbul ignore next */
continue
}
const fields = this.handleRelationshipType(field, {
name: field.fieldName,
type: FieldTypes.LINK,
// these are the props of the table that initiated the link
2023-10-06 06:47:00 +13:00
tableId: table._id!,
fieldName: fieldName,
2023-10-12 00:36:56 +13:00
} as RelationshipFieldMetadata)
2021-02-26 05:21:41 +13:00
// update table schema after checking relationship types
2021-03-17 02:24:44 +13:00
schema[fieldName] = fields.linkerField
const linkedField = fields.linkedField
2021-02-26 05:21:41 +13:00
if (field.autocolumn) {
linkedField.autocolumn = field.autocolumn
linkedField.subtype = field.subtype
2021-02-26 05:21:41 +13:00
}
// check the linked table to make sure we aren't overwriting an existing column
const existingSchema = linkedTable.schema[field.fieldName]
if (
existingSchema != null &&
2021-03-17 02:24:44 +13:00
!this.areLinkSchemasEqual(existingSchema, linkedField)
) {
2021-02-26 01:10:18 +13:00
throw new Error("Cannot overwrite existing column.")
}
// create the link field in the other table
linkedTable.schema[field.fieldName] = linkedField
const response = await this._db.put(linkedTable)
// special case for when linking back to self, make sure rev updated
if (linkedTable._id === table._id) {
table._rev = response.rev
}
}
}
return table
}
/**
* Update a table, this means if a field is removed need to handle removing from other table and removing
* any link docs that pertained to it.
* @returns The table which has been saved, same response as with the tableSaved function.
*/
async tableUpdated() {
const oldTable = this._oldTable
// first start by checking if any link columns have been deleted
const newTable = await this.table()
for (let fieldName of Object.keys(oldTable?.schema || {})) {
const field = oldTable?.schema[fieldName] as FieldSchema
// this field has been removed from the table schema
if (
field.type === FieldTypes.LINK &&
newTable.schema[fieldName] == null
) {
await this.removeFieldFromTable(fieldName)
}
}
// now handle as if its a new save
return this.tableSaved()
}
/**
* When a table is deleted this will carry out the necessary operations to make sure
* any linked tables have the joining column correctly removed as well as removing any
* now stale linking documents.
* @returns The operation has been completed and the link documents should now
* be accurate. Also returns the table that was operated on.
*/
async tableDeleted() {
const table = await this.table()
const schema = table.schema
for (let fieldName of Object.keys(schema)) {
const field = schema[fieldName]
try {
if (field.type === FieldTypes.LINK && field.fieldName) {
2023-07-18 21:41:51 +12:00
const linkedTable = await this._db.get<Table>(field.tableId)
delete linkedTable.schema[field.fieldName]
await this._db.put(linkedTable)
}
2023-10-18 23:07:50 +13:00
} catch (err: any) {
logging.logWarn(err?.message, err)
}
}
// need to get the full link docs to delete them
const linkDocs = await this.getTableLinkDocs()
if (linkDocs.length === 0) {
return null
}
// get link docs for this table and configure for deletion
2021-05-04 22:32:22 +12:00
const toDelete = linkDocs.map(doc => {
return {
...doc,
_deleted: true,
}
})
await this._db.bulkDocs(toDelete)
return table
}
}
export default LinkController