From 26a8fabfef3bc7858dcb03d38bcd88d948356450 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Wed, 9 Oct 2024 16:05:49 +0100 Subject: [PATCH 1/4] Prevent deleting rows through a calculation view. --- .../src/api/controllers/row/external.ts | 6 +++++ .../src/api/controllers/row/internal.ts | 24 ++++++++++++----- .../src/api/routes/tests/viewV2.spec.ts | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index 4fcd612ec3..082d07283b 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -28,6 +28,7 @@ import { import { cloneDeep } from "lodash" import { generateIdForRow } from "./utils" import { helpers } from "@budibase/shared-core" +import { HTTPError } from "@budibase/backend-core" export async function handleRequest( operation: T, @@ -102,6 +103,11 @@ export async function patch(ctx: UserCtx) { export async function destroy(ctx: UserCtx) { const source = await utils.getSource(ctx) + + if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) { + throw new HTTPError("Cannot delete rows through a calculation view", 400) + } + const _id = ctx.request.body._id const { row } = await handleRequest(Operation.DELETE, source, { id: breakRowIdField(_id), diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index 53b24d517c..e193f2e968 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -8,7 +8,7 @@ import { } from "../../../utilities/rowProcessor" import * as utils from "./utils" import { cloneDeep } from "lodash/fp" -import { context } from "@budibase/backend-core" +import { context, HTTPError } from "@budibase/backend-core" import { finaliseRow, updateRelatedFormula } from "./staticFormula" import { FieldType, @@ -16,6 +16,7 @@ import { PatchRowRequest, PatchRowResponse, Row, + Table, UserCtx, } from "@budibase/types" import sdk from "../../../sdk" @@ -97,15 +98,26 @@ export async function patch(ctx: UserCtx) { export async function destroy(ctx: UserCtx) { const db = context.getAppDB() - const { tableId } = utils.getSourceId(ctx) + const source = await utils.getSource(ctx) + + if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) { + throw new HTTPError("Cannot delete rows through a calculation view", 400) + } + + let table: Table + if (sdk.views.isView(source)) { + table = await sdk.views.getTable(source.id) + } else { + table = source + } + const { _id } = ctx.request.body let row = await db.get(_id) let _rev = ctx.request.body._rev || row._rev - if (row.tableId !== tableId) { + if (row.tableId !== table._id) { throw "Supplied tableId doesn't match the row's tableId" } - const table = await sdk.tables.getTable(tableId) // update the row to include full relationships before deleting them row = await outputProcessing(table, row, { squash: false, @@ -115,7 +127,7 @@ export async function destroy(ctx: UserCtx) { await linkRows.updateLinks({ eventType: linkRows.EventType.ROW_DELETE, row, - tableId, + tableId: table._id!, }) // remove any attachments that were on the row from object storage await AttachmentCleanup.rowDelete(table, [row]) @@ -123,7 +135,7 @@ export async function destroy(ctx: UserCtx) { await updateRelatedFormula(table, row) let response - if (tableId === InternalTables.USER_METADATA) { + if (table._id === InternalTables.USER_METADATA) { ctx.params = { id: _id, } diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 195481ae10..d268063b99 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -2148,6 +2148,32 @@ describe.each([ }) await config.api.row.get(table._id!, rows[1]._id!, { status: 200 }) }) + + it("should not be possible to delete a row in a calculation view", async () => { + const row = await config.api.row.save(table._id!, {}) + + const view = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + type: ViewV2Type.CALCULATION, + schema: { + id: { visible: true }, + one: { visible: true }, + }, + }) + + await config.api.row.delete( + view.id, + { _id: row._id! }, + { + status: 400, + body: { + message: "Cannot delete rows through a calculation view", + status: 400, + }, + } + ) + }) }) describe("read", () => { From 7546c9bbe29f99e468872b81753d66865562a939 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Wed, 9 Oct 2024 17:25:41 +0100 Subject: [PATCH 2/4] Test calculation view searching. --- .../src/api/routes/tests/viewV2.spec.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 195481ae10..b571a267e4 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -3157,6 +3157,128 @@ describe.each([ } ) }) + + it("should be able to filter rows to be calculated on on the view", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + quantity: { + type: FieldType.NUMBER, + name: "quantity", + }, + price: { + type: FieldType.NUMBER, + name: "price", + }, + }, + }) + ) + + const view = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + type: ViewV2Type.CALCULATION, + query: { + equal: { + quantity: 1, + }, + }, + schema: { + sum: { + visible: true, + calculationType: CalculationType.SUM, + field: "price", + }, + }, + }) + + await config.api.row.bulkImport(table._id!, { + rows: [ + { + quantity: 1, + price: 1, + }, + { + quantity: 1, + price: 2, + }, + { + quantity: 2, + price: 10, + }, + ], + }) + + const { rows } = await config.api.viewV2.search(view.id, { + query: {}, + }) + expect(rows).toHaveLength(1) + expect(rows[0].sum).toEqual(3) + }) + + it("should be able to filter on group by fields", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + quantity: { + type: FieldType.NUMBER, + name: "quantity", + }, + price: { + type: FieldType.NUMBER, + name: "price", + }, + }, + }) + ) + + const view = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + type: ViewV2Type.CALCULATION, + query: { + equal: { + quantity: 1, + }, + }, + schema: { + quantity: { visible: true }, + sum: { + visible: true, + calculationType: CalculationType.SUM, + field: "price", + }, + }, + }) + + await config.api.row.bulkImport(table._id!, { + rows: [ + { + quantity: 1, + price: 1, + }, + { + quantity: 1, + price: 2, + }, + { + quantity: 2, + price: 10, + }, + ], + }) + + const { rows } = await config.api.viewV2.search(view.id, { + query: { + equal: { + quantity: 1, + }, + }, + }) + + expect(rows).toHaveLength(1) + expect(rows[0].sum).toEqual(3) + }) }) !isLucene && From 4c0bd812c1cb2de03e2541d8f1b7e696d5822a6d Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Thu, 10 Oct 2024 10:37:47 +0100 Subject: [PATCH 3/4] Respond to PR comment. --- packages/server/src/api/routes/tests/viewV2.spec.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index b571a267e4..e677fe302c 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -3158,7 +3158,7 @@ describe.each([ ) }) - it("should be able to filter rows to be calculated on on the view", async () => { + it("should be able to filter rows on the view itself", async () => { const table = await config.api.table.save( saveTableRequest({ schema: { @@ -3236,11 +3236,6 @@ describe.each([ tableId: table._id!, name: generator.guid(), type: ViewV2Type.CALCULATION, - query: { - equal: { - quantity: 1, - }, - }, schema: { quantity: { visible: true }, sum: { From 28c22e9aa7ba44dcea85d4073a183509a686c9f6 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Thu, 10 Oct 2024 11:50:38 +0100 Subject: [PATCH 4/4] Fix table updates wiping out view calculation fields. --- .../src/api/routes/tests/viewV2.spec.ts | 53 +++++++++++++++++++ .../server/src/api/routes/utils/validators.ts | 43 ++++++++------- packages/server/src/sdk/app/views/index.ts | 10 +++- 3 files changed, 83 insertions(+), 23 deletions(-) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index e677fe302c..6ea2834373 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -1904,6 +1904,59 @@ describe.each([ }) }) }) + + describe("calculation views", () => { + it("should not remove calculation columns when modifying table schema", async () => { + let table = await config.api.table.save( + saveTableRequest({ + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + age: { + name: "age", + type: FieldType.NUMBER, + }, + }, + }) + ) + + let view = await config.api.viewV2.create({ + tableId: table._id!, + name: generator.guid(), + type: ViewV2Type.CALCULATION, + schema: { + sum: { + visible: true, + calculationType: CalculationType.SUM, + field: "age", + }, + }, + }) + + table = await config.api.table.get(table._id!) + await config.api.table.save({ + ...table, + schema: { + ...table.schema, + name: { + name: "name", + type: FieldType.STRING, + constraints: { presence: true }, + }, + }, + }) + + view = await config.api.viewV2.get(view.id) + expect(Object.keys(view.schema!).sort()).toEqual([ + "age", + "id", + "name", + "sum", + ]) + }) + }) }) describe("row operations", () => { diff --git a/packages/server/src/api/routes/utils/validators.ts b/packages/server/src/api/routes/utils/validators.ts index b589d44b31..22b8d32978 100644 --- a/packages/server/src/api/routes/utils/validators.ts +++ b/packages/server/src/api/routes/utils/validators.ts @@ -20,29 +20,28 @@ const OPTIONAL_NUMBER = Joi.number().optional().allow(null) const OPTIONAL_BOOLEAN = Joi.boolean().optional().allow(null) const APP_NAME_REGEX = /^[\w\s]+$/ -const validateViewSchemas: CustomValidator = (table, helpers) => { - if (table.views && Object.entries(table.views).length) { - const requiredFields = Object.entries(table.schema) - .filter(([_, v]) => isRequired(v.constraints)) +const validateViewSchemas: CustomValidator
= (table, joiHelpers) => { + if (!table.views || Object.keys(table.views).length === 0) { + return table + } + const required = Object.keys(table.schema).filter(key => + isRequired(table.schema[key].constraints) + ) + if (required.length === 0) { + return table + } + for (const view of Object.values(table.views)) { + if (!sdk.views.isV2(view) || helpers.views.isCalculationView(view)) { + continue + } + const editable = Object.entries(view.schema || {}) + .filter(([_, f]) => f.visible && !f.readonly) .map(([key]) => key) - if (requiredFields.length) { - for (const view of Object.values(table.views)) { - if (!sdk.views.isV2(view)) { - continue - } - - const editableViewFields = Object.entries(view.schema || {}) - .filter(([_, f]) => f.visible && !f.readonly) - .map(([key]) => key) - const missingField = requiredFields.find( - f => !editableViewFields.includes(f) - ) - if (missingField) { - return helpers.message({ - custom: `To make field "${missingField}" required, this field must be present and writable in views: ${view.name}.`, - }) - } - } + const missingField = required.find(f => !editable.includes(f)) + if (missingField) { + return joiHelpers.message({ + custom: `To make field "${missingField}" required, this field must be present and writable in views: ${view.name}.`, + }) } } return table diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index 629454fecc..3549882199 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -96,6 +96,13 @@ async function guardCalculationViewSchema( continue } + if (!schema.field) { + throw new HTTPError( + `Calculation field "${name}" is missing a "field" property`, + 400 + ) + } + const targetSchema = table.schema[schema.field] if (!targetSchema) { throw new HTTPError( @@ -371,7 +378,8 @@ export function syncSchema( if (view.schema) { for (const fieldName of Object.keys(view.schema)) { - if (!schema[fieldName]) { + const viewSchema = view.schema[fieldName] + if (!helpers.views.isCalculationField(viewSchema) && !schema[fieldName]) { delete view.schema[fieldName] } }