Adding the formula bulk recalculation when adding/changing a formula field.

This commit is contained in:
mike12345567 2022-01-25 16:01:04 +00:00
parent c7c0842d7a
commit 1fade3404f
6 changed files with 257 additions and 191 deletions

View File

@ -1,8 +1,8 @@
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const linkRows = require("../../../db/linkedRows") const linkRows = require("../../../db/linkedRows")
const { const {
getRowParams,
generateRowID, generateRowID,
getRowParams,
DocumentTypes, DocumentTypes,
InternalTables, InternalTables,
} = require("../../../db/utils") } = require("../../../db/utils")
@ -10,12 +10,9 @@ const userController = require("../user")
const { const {
inputProcessing, inputProcessing,
outputProcessing, outputProcessing,
processAutoColumn,
cleanupAttachments, cleanupAttachments,
processFormulas,
} = require("../../../utilities/rowProcessor") } = require("../../../utilities/rowProcessor")
const { FieldTypes, FormulaTypes } = require("../../../constants") const { FieldTypes } = require("../../../constants")
const { isEqual } = require("lodash")
const { validate, findRow } = require("./utils") const { validate, findRow } = require("./utils")
const { fullSearch, paginatedSearch } = require("./internalSearch") const { fullSearch, paginatedSearch } = require("./internalSearch")
const { getGlobalUsersFromMetadata } = require("../../../utilities/global") const { getGlobalUsersFromMetadata } = require("../../../utilities/global")
@ -28,6 +25,7 @@ const {
getFromMemoryDoc, getFromMemoryDoc,
} = require("../view/utils") } = require("../view/utils")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { finaliseRow, updateRelatedFormula } = require("./staticFormula")
const CALCULATION_TYPES = { const CALCULATION_TYPES = {
SUM: "sum", SUM: "sum",
@ -35,135 +33,6 @@ const CALCULATION_TYPES = {
STATS: "stats", STATS: "stats",
} }
/**
* This function runs through a list of enriched rows, looks at the rows which
* are related and then checks if they need the state of their formulas
* updated.
* NOTE: this will only for affect static formulas.
*/
async function updateRelatedFormula(appId, db, table, enrichedRows) {
// no formula to update, we're done
if (!table.relatedFormula) {
return
}
let promises = []
for (let enrichedRow of Array.isArray(enrichedRows)
? enrichedRows
: [enrichedRows]) {
// the related rows by tableId
let relatedRows = {}
for (let [key, field] of Object.entries(enrichedRow)) {
const columnDefinition = table.schema[key]
if (columnDefinition && columnDefinition.type === FieldTypes.LINK) {
const relatedTableId = columnDefinition.tableId
if (!relatedRows[relatedTableId]) {
relatedRows[relatedTableId] = []
}
relatedRows[relatedTableId] = relatedRows[relatedTableId].concat(field)
}
}
for (let tableId of table.relatedFormula) {
try {
// no rows to update, skip
if (!relatedRows[tableId] || relatedRows[tableId].length === 0) {
continue
}
const relatedTable = await db.get(tableId)
for (let column of Object.values(relatedTable.schema)) {
// needs updated in related rows
if (
column.type === FieldTypes.FORMULA &&
column.formulaType === FormulaTypes.STATIC
) {
// re-enrich rows for all the related, don't update the related formula for them
promises = promises.concat(
relatedRows[tableId].map(related =>
storeResponse(appId, db, relatedTable, related, {
updateFormula: false,
})
)
)
}
}
} catch (err) {
// no error scenario, table doesn't seem to exist anymore, ignore
}
}
}
await Promise.all(promises)
}
/**
* This function runs at the end of the save/patch functions of the row controller, all this
* really does is enrich the row, handle any static formula processing, then return the enriched
* row. The reason we need to return the enriched row is that the automation row created trigger
* expects the row to be totally enriched/contain all relationships.
*/
async function storeResponse(
appId,
db,
table,
row,
{ oldTable, updateFormula } = { updateFormula: true }
) {
row.type = "row"
// process the row before return, to include relationships
let enrichedRow = await outputProcessing({ appId }, table, cloneDeep(row), {
squash: false,
})
// use enriched row to generate formulas for saving, specifically only use as context
row = processFormulas(table, row, {
dynamic: false,
contextRows: enrichedRow,
})
// don't worry about rev, tables handle rev/lastID updates
// if another row has been written since processing this will
// handle the auto ID clash
if (oldTable && !isEqual(oldTable, table)) {
try {
await db.put(table)
} catch (err) {
if (err.status === 409) {
const updatedTable = await db.get(table._id)
let response = processAutoColumn(null, updatedTable, row, {
reprocessing: true,
})
await db.put(response.table)
row = response.row
} else {
throw err
}
}
}
const response = await db.put(row)
// for response, calculate the formulas for the enriched row
enrichedRow._rev = response.rev
enrichedRow = await processFormulas(table, enrichedRow, { dynamic: false })
// this updates the related formulas in other rows based on the relations to this row
if (updateFormula) {
await updateRelatedFormula(appId, db, table, enrichedRow)
}
return { row: enrichedRow, table }
}
// doesn't do the outputProcessing
async function getRawTableData(ctx, db, tableId) {
let rows
if (tableId === InternalTables.USER_METADATA) {
await userController.fetchMetadata(ctx)
rows = ctx.body
} else {
const response = await db.allDocs(
getRowParams(tableId, null, {
include_docs: true,
})
)
rows = response.rows.map(row => row.doc)
}
return rows
}
async function getView(db, viewName) { async function getView(db, viewName) {
let mainGetter = env.SELF_HOSTED ? getFromDesignDoc : getFromMemoryDoc let mainGetter = env.SELF_HOSTED ? getFromDesignDoc : getFromMemoryDoc
let secondaryGetter = env.SELF_HOSTED ? getFromMemoryDoc : getFromDesignDoc let secondaryGetter = env.SELF_HOSTED ? getFromMemoryDoc : getFromDesignDoc
@ -190,6 +59,22 @@ async function getView(db, viewName) {
return viewInfo return viewInfo
} }
async function getRawTableData(ctx, db, tableId) {
let rows
if (tableId === InternalTables.USER_METADATA) {
await userController.fetchMetadata(ctx)
rows = ctx.body
} else {
const response = await db.allDocs(
getRowParams(tableId, null, {
include_docs: true,
})
)
rows = response.rows.map(row => row.doc)
}
return rows
}
exports.patch = async ctx => { exports.patch = async ctx => {
const appId = ctx.appId const appId = ctx.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)
@ -247,7 +132,7 @@ exports.patch = async ctx => {
return { row: ctx.body, table } return { row: ctx.body, table }
} }
return storeResponse(ctx.appId, db, table, row, { return finaliseRow(ctx.appId, table, row, {
oldTable: dbTable, oldTable: dbTable,
updateFormula: true, updateFormula: true,
}) })
@ -284,7 +169,7 @@ exports.save = async function (ctx) {
table, table,
}) })
return storeResponse(ctx.appId, db, table, row, { return finaliseRow(ctx.appId, table, row, {
oldTable: dbTable, oldTable: dbTable,
updateFormula: true, updateFormula: true,
}) })
@ -394,7 +279,7 @@ exports.destroy = async function (ctx) {
// remove any attachments that were on the row from object storage // remove any attachments that were on the row from object storage
await cleanupAttachments(appId, table, { row }) await cleanupAttachments(appId, table, { row })
// remove any static formula // remove any static formula
await updateRelatedFormula(appId, db, table, row) await updateRelatedFormula(appId, table, row)
let response let response
if (ctx.params.tableId === InternalTables.USER_METADATA) { if (ctx.params.tableId === InternalTables.USER_METADATA) {
@ -443,7 +328,7 @@ exports.bulkDestroy = async ctx => {
} }
// remove any attachments that were on the rows from object storage // remove any attachments that were on the rows from object storage
await cleanupAttachments(appId, table, { rows }) await cleanupAttachments(appId, table, { rows })
await updateRelatedFormula(appId, db, table, rows) await updateRelatedFormula(appId, table, rows)
await Promise.all(updates) await Promise.all(updates)
return { response: { ok: true }, rows } return { response: { ok: true }, rows }
} }

View File

@ -0,0 +1,156 @@
const CouchDB = require("../../../db")
const { getRowParams } = require("../../../db/utils")
const {
outputProcessing,
processAutoColumn,
processFormulas,
} = require("../../../utilities/rowProcessor")
const { FieldTypes, FormulaTypes } = require("../../../constants")
const { isEqual } = require("lodash")
const { cloneDeep } = require("lodash/fp")
/**
* This function runs through a list of enriched rows, looks at the rows which
* are related and then checks if they need the state of their formulas
* updated.
* NOTE: this will only for affect static formulas.
*/
exports.updateRelatedFormula = async (appId, table, enrichedRows) => {
const db = new CouchDB(appId)
// no formula to update, we're done
if (!table.relatedFormula) {
return
}
let promises = []
for (let enrichedRow of Array.isArray(enrichedRows)
? enrichedRows
: [enrichedRows]) {
// the related rows by tableId
let relatedRows = {}
for (let [key, field] of Object.entries(enrichedRow)) {
const columnDefinition = table.schema[key]
if (columnDefinition && columnDefinition.type === FieldTypes.LINK) {
const relatedTableId = columnDefinition.tableId
if (!relatedRows[relatedTableId]) {
relatedRows[relatedTableId] = []
}
relatedRows[relatedTableId] = relatedRows[relatedTableId].concat(field)
}
}
for (let tableId of table.relatedFormula) {
try {
// no rows to update, skip
if (!relatedRows[tableId] || relatedRows[tableId].length === 0) {
continue
}
const relatedTable = await db.get(tableId)
for (let column of Object.values(relatedTable.schema)) {
// needs updated in related rows
if (
column.type === FieldTypes.FORMULA &&
column.formulaType === FormulaTypes.STATIC
) {
// re-enrich rows for all the related, don't update the related formula for them
promises = promises.concat(
relatedRows[tableId].map(related =>
exports.finaliseRow(appId, relatedTable, related, {
updateFormula: false,
})
)
)
}
}
} catch (err) {
// no error scenario, table doesn't seem to exist anymore, ignore
}
}
}
await Promise.all(promises)
}
exports.updateAllFormulasInTable = async (appId, table) => {
const db = new CouchDB(appId)
// start by getting the raw rows (which will be written back to DB after update)
let rows = (
await db.allDocs(
getRowParams(table._id, null, {
include_docs: true,
})
)
).rows.map(row => row.doc)
// now enrich the rows, note the clone so that we have the base state of the
// rows so that we don't write any of the enriched information back
let enrichedRows = await outputProcessing({ appId }, table, cloneDeep(rows), {
squash: false,
})
const updatedRows = []
for (let row of rows) {
// find the enriched row, if found process the formulas
const enrichedRow = enrichedRows.find(enriched => enriched._id === row._id)
if (enrichedRow) {
const processed = processFormulas(table, cloneDeep(row), {
dynamic: false,
contextRows: enrichedRow,
})
// values have changed, need to add to bulk docs to update
if (!isEqual(processed, row)) {
updatedRows.push(processed)
}
}
}
await db.bulkDocs(updatedRows)
}
/**
* This function runs at the end of the save/patch functions of the row controller, all this
* really does is enrich the row, handle any static formula processing, then return the enriched
* row. The reason we need to return the enriched row is that the automation row created trigger
* expects the row to be totally enriched/contain all relationships.
*/
exports.finaliseRow = async (
appId,
table,
row,
{ oldTable, updateFormula } = { updateFormula: true }
) => {
const db = new CouchDB(appId)
row.type = "row"
// process the row before return, to include relationships
let enrichedRow = await outputProcessing({ appId }, table, cloneDeep(row), {
squash: false,
})
// use enriched row to generate formulas for saving, specifically only use as context
row = processFormulas(table, row, {
dynamic: false,
contextRows: enrichedRow,
})
// don't worry about rev, tables handle rev/lastID updates
// if another row has been written since processing this will
// handle the auto ID clash
if (oldTable && !isEqual(oldTable, table)) {
try {
await db.put(table)
} catch (err) {
if (err.status === 409) {
const updatedTable = await db.get(table._id)
let response = processAutoColumn(null, updatedTable, row, {
reprocessing: true,
})
await db.put(response.table)
row = response.row
} else {
throw err
}
}
}
const response = await db.put(row)
// for response, calculate the formulas for the enriched row
enrichedRow._rev = response.rev
enrichedRow = await processFormulas(table, enrichedRow, { dynamic: false })
// this updates the related formulas in other rows based on the relations to this row
if (updateFormula) {
await exports.updateRelatedFormula(appId, table, enrichedRow)
}
return { row: enrichedRow, table }
}

View File

@ -1,8 +1,17 @@
const CouchDB = require("../../../db")
const { FieldTypes, FormulaTypes } = require("../../../constants") const { FieldTypes, FormulaTypes } = require("../../../constants")
const { getAllInternalTables, clearColumns } = require("./utils") const { getAllInternalTables, clearColumns } = require("./utils")
const { doesContainStrings } = require("@budibase/string-templates") const { doesContainStrings } = require("@budibase/string-templates")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { isEqual, uniq } = require("lodash") const { isEqual, uniq } = require("lodash")
const { updateAllFormulasInTable } = require("../row/staticFormula")
function isStaticFormula(column) {
return (
column.type === FieldTypes.FORMULA &&
column.formulaType === FormulaTypes.STATIC
)
}
/** /**
* This retrieves the formula columns from a table schema that use a specified column name * This retrieves the formula columns from a table schema that use a specified column name
@ -13,10 +22,7 @@ function getFormulaThatUseColumn(table, columnNames) {
columnNames = Array.isArray(columnNames) ? columnNames : [columnNames] columnNames = Array.isArray(columnNames) ? columnNames : [columnNames]
for (let column of Object.values(table.schema)) { for (let column of Object.values(table.schema)) {
// not a static formula, or doesn't contain a relationship // not a static formula, or doesn't contain a relationship
if ( if (!isStaticFormula(column)) {
column.type !== FieldTypes.FORMULA ||
column.formulaType !== FormulaTypes.STATIC
) {
continue continue
} }
if (!doesContainStrings(column.formula, columnNames)) { if (!doesContainStrings(column.formula, columnNames)) {
@ -28,16 +34,18 @@ function getFormulaThatUseColumn(table, columnNames) {
} }
/** /**
* This functions checks two things: * This functions checks for when a related table, column or related column is deleted, if any
* 1. when a related table, column or related column is deleted, if any
* tables need to have the formula column removed. * tables need to have the formula column removed.
* 2. If a formula has been added, or updated bulk update all the rows
* in the table as per the new formula.
*/ */
async function checkRequiredFormulaUpdates(db, table, { oldTable, deletion }) { async function checkIfFormulaNeedsCleared(
appId,
table,
{ oldTable, deletion }
) {
const db = new CouchDB(appId)
// start by retrieving all tables, remove the current table from the list // start by retrieving all tables, remove the current table from the list
const tables = (await getAllInternalTables({ db })).filter( const tables = (await getAllInternalTables(appId)).filter(
tbl => tbl._id !== table._id tbl => tbl._id !== table._id
) )
const schemaToUse = oldTable ? oldTable.schema : table.schema const schemaToUse = oldTable ? oldTable.schema : table.schema
@ -57,7 +65,9 @@ async function checkRequiredFormulaUpdates(db, table, { oldTable, deletion }) {
} }
// need a special case, where a column has been removed from this table, but was used // need a special case, where a column has been removed from this table, but was used
// in a different, related tables formula // in a different, related tables formula
if (table.relatedFormula) { if (!table.relatedFormula) {
continue
}
for (let relatedTableId of table.relatedFormula) { for (let relatedTableId of table.relatedFormula) {
const relatedColumns = Object.values(table.schema).filter( const relatedColumns = Object.values(table.schema).filter(
column => column.tableId === relatedTableId column => column.tableId === relatedTableId
@ -65,11 +75,7 @@ async function checkRequiredFormulaUpdates(db, table, { oldTable, deletion }) {
const relatedTable = tables.find(table => table._id === relatedTableId) const relatedTable = tables.find(table => table._id === relatedTableId)
// look to see if the column was used in a relationship formula, // look to see if the column was used in a relationship formula,
// relationships won't be used for this // relationships won't be used for this
if ( if (relatedTable && relatedColumns && removed.type !== FieldTypes.LINK) {
relatedTable &&
relatedColumns &&
removed.type !== FieldTypes.LINK
) {
let relatedFormulaToRemove = [] let relatedFormulaToRemove = []
for (let column of relatedColumns) { for (let column of relatedColumns) {
relatedFormulaToRemove = relatedFormulaToRemove.concat( relatedFormulaToRemove = relatedFormulaToRemove.concat(
@ -85,7 +91,6 @@ async function checkRequiredFormulaUpdates(db, table, { oldTable, deletion }) {
} }
} }
} }
}
} }
/** /**
@ -95,12 +100,13 @@ async function checkRequiredFormulaUpdates(db, table, { oldTable, deletion }) {
* specifically only for static formula. * specifically only for static formula.
*/ */
async function updateRelatedFormulaLinksOnTables( async function updateRelatedFormulaLinksOnTables(
db, appId,
table, table,
{ deletion } = { deletion: false } { deletion } = { deletion: false }
) { ) {
const db = new CouchDB(appId)
// start by retrieving all tables, remove the current table from the list // start by retrieving all tables, remove the current table from the list
const tables = (await getAllInternalTables({ db })).filter( const tables = (await getAllInternalTables(appId)).filter(
tbl => tbl._id !== table._id tbl => tbl._id !== table._id
) )
// clone the tables, so we can compare at end // clone the tables, so we can compare at end
@ -150,7 +156,29 @@ async function updateRelatedFormulaLinksOnTables(
} }
} }
exports.runStaticFormulaChecks = async (db, table, { oldTable, deletion }) => { async function checkIfFormulaUpdated(appId, table, { oldTable }) {
await updateRelatedFormulaLinksOnTables(db, table, { deletion }) // look to see if any formula values have changed
await checkRequiredFormulaUpdates(db, table, { oldTable, deletion }) const shouldUpdate = Object.values(table.schema).find(
column =>
isStaticFormula(column) &&
(!oldTable ||
!oldTable.schema[column.name] ||
!isEqual(oldTable.schema[column.name], column))
)
// if a static formula column has updated, then need to run the update
if (shouldUpdate != null) {
await updateAllFormulasInTable(appId, table)
}
}
exports.runStaticFormulaChecks = async (
appId,
table,
{ oldTable, deletion }
) => {
await updateRelatedFormulaLinksOnTables(appId, table, { deletion })
await checkIfFormulaNeedsCleared(appId, table, { oldTable, deletion })
if (!deletion) {
await checkIfFormulaUpdated(appId, table, { oldTable })
}
} }

View File

@ -22,7 +22,7 @@ function pickApi({ tableId, table }) {
exports.fetch = async function (ctx) { exports.fetch = async function (ctx) {
const db = new CouchDB(ctx.appId) const db = new CouchDB(ctx.appId)
const internal = await getAllInternalTables({ db }) const internal = await getAllInternalTables(ctx.appId)
const externalTables = await db.allDocs( const externalTables = await db.allDocs(
getDatasourceParams("plus", { getDatasourceParams("plus", {

View File

@ -107,7 +107,7 @@ exports.save = async function (ctx) {
tableToSave = await tableSaveFunctions.after(tableToSave) tableToSave = await tableSaveFunctions.after(tableToSave)
// has to run after, make sure it has _id // has to run after, make sure it has _id
await runStaticFormulaChecks(db, tableToSave, { oldTable }) await runStaticFormulaChecks(appId, tableToSave, { oldTable })
return tableToSave return tableToSave
} }
@ -145,7 +145,7 @@ exports.destroy = async function (ctx) {
} }
// has to run after, make sure it has _id // has to run after, make sure it has _id
await runStaticFormulaChecks(db, tableToDelete, { deletion: true }) await runStaticFormulaChecks(appId, tableToDelete, { deletion: true })
await cleanupAttachments(appId, tableToDelete, { rows }) await cleanupAttachments(appId, tableToDelete, { rows })
return tableToDelete return tableToDelete
} }

View File

@ -24,7 +24,8 @@ const viewTemplate = require("../view/viewBuilder")
const usageQuota = require("../../../utilities/usageQuota") const usageQuota = require("../../../utilities/usageQuota")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
exports.clearColumns = async (db, table, columnNames) => { exports.clearColumns = async (appId, table, columnNames) => {
const db = new CouchDB(appId)
const rows = await db.allDocs( const rows = await db.allDocs(
getRowParams(table._id, null, { getRowParams(table._id, null, {
include_docs: true, include_docs: true,
@ -255,10 +256,8 @@ class TableSaveFunctions {
} }
} }
exports.getAllInternalTables = async ({ db, appId }) => { exports.getAllInternalTables = async appId => {
if (appId && !db) { const db = new CouchDB(appId)
db = new CouchDB(appId)
}
const internalTables = await db.allDocs( const internalTables = await db.allDocs(
getTableParams(null, { getTableParams(null, {
include_docs: true, include_docs: true,
@ -271,10 +270,8 @@ exports.getAllInternalTables = async ({ db, appId }) => {
})) }))
} }
exports.getAllExternalTables = async ({ db, appId }, datasourceId) => { exports.getAllExternalTables = async (appId, datasourceId) => {
if (appId && !db) { const db = new CouchDB(appId)
db = new CouchDB(appId)
}
const datasource = await db.get(datasourceId) const datasource = await db.get(datasourceId)
if (!datasource || !datasource.entities) { if (!datasource || !datasource.entities) {
throw "Datasource is not configured fully." throw "Datasource is not configured fully."
@ -283,7 +280,7 @@ exports.getAllExternalTables = async ({ db, appId }, datasourceId) => {
} }
exports.getExternalTable = async (appId, datasourceId, tableName) => { exports.getExternalTable = async (appId, datasourceId, tableName) => {
const entities = await exports.getAllExternalTables({ appId }, datasourceId) const entities = await exports.getAllExternalTables(appId, datasourceId)
return entities[tableName] return entities[tableName]
} }