diff --git a/packages/backend-core/src/middleware/joi-validator.ts b/packages/backend-core/src/middleware/joi-validator.ts
index ac8064a512..5047cdbbc1 100644
--- a/packages/backend-core/src/middleware/joi-validator.ts
+++ b/packages/backend-core/src/middleware/joi-validator.ts
@@ -3,7 +3,8 @@ import { Ctx } from "@budibase/types"
function validate(
schema: Joi.ObjectSchema | Joi.ArraySchema,
- property: string
+ property: string,
+ opts: { errorPrefix: string } = { errorPrefix: `Invalid ${property}` }
) {
// Return a Koa middleware function
return (ctx: Ctx, next: any) => {
@@ -29,16 +30,26 @@ function validate(
const { error } = schema.validate(params)
if (error) {
- ctx.throw(400, `Invalid ${property} - ${error.message}`)
+ let message = error.message
+ if (opts.errorPrefix) {
+ message = `Invalid ${property} - ${message}`
+ }
+ ctx.throw(400, message)
}
return next()
}
}
-export function body(schema: Joi.ObjectSchema | Joi.ArraySchema) {
- return validate(schema, "body")
+export function body(
+ schema: Joi.ObjectSchema | Joi.ArraySchema,
+ opts?: { errorPrefix: string }
+) {
+ return validate(schema, "body", opts)
}
-export function params(schema: Joi.ObjectSchema | Joi.ArraySchema) {
- return validate(schema, "params")
+export function params(
+ schema: Joi.ObjectSchema | Joi.ArraySchema,
+ opts?: { errorPrefix: string }
+) {
+ return validate(schema, "params", opts)
}
diff --git a/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte b/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte
index 3f0e2341be..228cf69e34 100644
--- a/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte
+++ b/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte
@@ -19,11 +19,17 @@
const visible = permission !== PERMISSION_OPTIONS.HIDDEN
const readonly = permission === PERMISSION_OPTIONS.READONLY
- datasource.actions.addSchemaMutation(column.name, { visible, readonly })
+ await datasource.actions.addSchemaMutation(column.name, {
+ visible,
+ readonly,
+ })
try {
await datasource.actions.saveSchemaMutations()
} catch (e) {
notifications.error(e.message)
+ } finally {
+ await datasource.actions.resetSchemaMutations()
+ await datasource.actions.refreshDefinition()
}
dispatch(visible ? "show-column" : "hide-column")
}
diff --git a/packages/frontend-core/src/components/grid/stores/datasource.js b/packages/frontend-core/src/components/grid/stores/datasource.js
index 1fc973f171..09b8be4868 100644
--- a/packages/frontend-core/src/components/grid/stores/datasource.js
+++ b/packages/frontend-core/src/components/grid/stores/datasource.js
@@ -204,6 +204,10 @@ export const createActions = context => {
...$definition,
schema: newSchema,
})
+ resetSchemaMutations()
+ }
+
+ const resetSchemaMutations = () => {
schemaMutations.set({})
}
@@ -253,6 +257,7 @@ export const createActions = context => {
addSchemaMutation,
addSchemaMutations,
saveSchemaMutations,
+ resetSchemaMutations,
},
},
}
diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts
index 962d6e82a3..cf9db761ce 100644
--- a/packages/server/src/api/routes/tests/viewV2.spec.ts
+++ b/packages/server/src/api/routes/tests/viewV2.spec.ts
@@ -23,9 +23,6 @@ import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import merge from "lodash/merge"
import { quotas } from "@budibase/pro"
import { roles } from "@budibase/backend-core"
-import * as schemaUtils from "../../../utilities/schema"
-
-jest.mock("../../../utilities/schema")
describe.each([
["internal", undefined],
@@ -318,15 +315,13 @@ describe.each([
})
it("required fields cannot be marked as readonly", async () => {
- const isRequiredSpy = jest.spyOn(schemaUtils, "isRequired")
- isRequiredSpy.mockReturnValueOnce(true)
-
const table = await config.api.table.save(
saveTableRequest({
schema: {
name: {
name: "name",
type: FieldType.STRING,
+ constraints: { presence: true },
},
description: {
name: "description",
@@ -341,6 +336,7 @@ describe.each([
tableId: table._id!,
schema: {
name: {
+ visible: true,
readonly: true,
},
},
@@ -350,7 +346,7 @@ describe.each([
status: 400,
body: {
message:
- 'Field "name" cannot be readonly as it is a required field',
+ 'You can\'t make field "name" readonly because it is a required field.',
status: 400,
},
})
@@ -1348,4 +1344,123 @@ describe.each([
})
})
})
+
+ describe("updating table schema", () => {
+ describe("existing columns changed to required", () => {
+ beforeEach(async () => {
+ table = await config.api.table.save(
+ saveTableRequest({
+ schema: {
+ id: {
+ name: "id",
+ type: FieldType.AUTO,
+ autocolumn: true,
+ },
+ name: {
+ name: "name",
+ type: FieldType.STRING,
+ },
+ },
+ })
+ )
+ })
+
+ it("allows updating when no views constrains the field", async () => {
+ await config.api.viewV2.create({
+ name: "view a",
+ tableId: table._id!,
+ schema: {
+ id: { visible: true },
+ name: { visible: true },
+ },
+ })
+
+ 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: { allowEmpty: false } },
+ },
+ },
+ },
+ { status: 200 }
+ )
+ })
+
+ it("rejects if field is readonly in any view", async () => {
+ mocks.licenses.useViewReadonlyColumns()
+
+ await config.api.viewV2.create({
+ name: "view a",
+ tableId: table._id!,
+ schema: {
+ id: { visible: true },
+ name: {
+ visible: true,
+ readonly: true,
+ },
+ },
+ })
+
+ 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 },
+ },
+ },
+ },
+ {
+ status: 400,
+ body: {
+ status: 400,
+ message:
+ 'To make field "name" required, this field must be present and writable in views: view a.',
+ },
+ }
+ )
+ })
+
+ it("rejects if field is hidden in any view", async () => {
+ await config.api.viewV2.create({
+ name: "view a",
+ tableId: table._id!,
+ schema: { id: { visible: true } },
+ })
+
+ 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 },
+ },
+ },
+ },
+ {
+ status: 400,
+ body: {
+ status: 400,
+ message:
+ 'To make field "name" required, this field must be present and writable in views: view a.',
+ },
+ }
+ )
+ })
+ })
+ })
})
diff --git a/packages/server/src/api/routes/utils/validators.ts b/packages/server/src/api/routes/utils/validators.ts
index 424d0d6c79..e2cc463f38 100644
--- a/packages/server/src/api/routes/utils/validators.ts
+++ b/packages/server/src/api/routes/utils/validators.ts
@@ -1,51 +1,88 @@
import { auth, permissions } from "@budibase/backend-core"
import { DataSourceOperation } from "../../../constants"
-import { WebhookActionType } from "@budibase/types"
-import Joi from "joi"
+import { Table, WebhookActionType } from "@budibase/types"
+import Joi, { CustomValidator } from "joi"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
+import { isRequired } from "../../../utilities/schema"
+import sdk from "../../../sdk"
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
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))
+ .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}.`,
+ })
+ }
+ }
+ }
+ }
+ return table
+}
+
export function tableValidator() {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- _id: OPTIONAL_STRING,
- _rev: OPTIONAL_STRING,
- type: OPTIONAL_STRING.valid("table", "internal", "external"),
- primaryDisplay: OPTIONAL_STRING,
- schema: Joi.object().required(),
- name: Joi.string().required(),
- views: Joi.object(),
- rows: Joi.array(),
- }).unknown(true))
+ return auth.joiValidator.body(
+ Joi.object({
+ _id: OPTIONAL_STRING,
+ _rev: OPTIONAL_STRING,
+ type: OPTIONAL_STRING.valid("table", "internal", "external"),
+ primaryDisplay: OPTIONAL_STRING,
+ schema: Joi.object().required(),
+ name: Joi.string().required(),
+ views: Joi.object(),
+ rows: Joi.array(),
+ })
+ .custom(validateViewSchemas)
+ .unknown(true),
+ { errorPrefix: "" }
+ )
}
export function nameValidator() {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- name: OPTIONAL_STRING,
- }))
+ return auth.joiValidator.body(
+ Joi.object({
+ name: OPTIONAL_STRING,
+ })
+ )
}
export function datasourceValidator() {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- _id: Joi.string(),
- _rev: Joi.string(),
- type: OPTIONAL_STRING.allow("datasource_plus"),
- relationships: Joi.array().items(Joi.object({
- from: Joi.string().required(),
- to: Joi.string().required(),
- cardinality: Joi.valid("1:N", "1:1", "N:N").required()
- })),
- }).unknown(true))
+ return auth.joiValidator.body(
+ Joi.object({
+ _id: Joi.string(),
+ _rev: Joi.string(),
+ type: OPTIONAL_STRING.allow("datasource_plus"),
+ relationships: Joi.array().items(
+ Joi.object({
+ from: Joi.string().required(),
+ to: Joi.string().required(),
+ cardinality: Joi.valid("1:N", "1:1", "N:N").required(),
+ })
+ ),
+ }).unknown(true)
+ )
}
function filterObject() {
- // prettier-ignore
return Joi.object({
string: Joi.object().optional(),
fuzzy: Joi.object().optional(),
@@ -62,17 +99,20 @@ function filterObject() {
}
export function internalSearchValidator() {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- tableId: OPTIONAL_STRING,
- query: filterObject(),
- limit: OPTIONAL_NUMBER,
- sort: OPTIONAL_STRING,
- sortOrder: OPTIONAL_STRING,
- sortType: OPTIONAL_STRING,
- paginate: Joi.boolean(),
- bookmark: Joi.alternatives().try(OPTIONAL_STRING, OPTIONAL_NUMBER).optional(),
- }))
+ return auth.joiValidator.body(
+ Joi.object({
+ tableId: OPTIONAL_STRING,
+ query: filterObject(),
+ limit: OPTIONAL_NUMBER,
+ sort: OPTIONAL_STRING,
+ sortOrder: OPTIONAL_STRING,
+ sortType: OPTIONAL_STRING,
+ paginate: Joi.boolean(),
+ bookmark: Joi.alternatives()
+ .try(OPTIONAL_STRING, OPTIONAL_NUMBER)
+ .optional(),
+ })
+ )
}
export function externalSearchValidator() {
@@ -94,92 +134,110 @@ export function externalSearchValidator() {
}
export function datasourceQueryValidator() {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- endpoint: Joi.object({
- datasourceId: Joi.string().required(),
- operation: Joi.string().required().valid(...Object.values(DataSourceOperation)),
- entityId: Joi.string().required(),
- }).required(),
- resource: Joi.object({
- fields: Joi.array().items(Joi.string()).optional(),
- }).optional(),
- body: Joi.object().optional(),
- sort: Joi.object().optional(),
- filters: filterObject().optional(),
- paginate: Joi.object({
- page: Joi.string().alphanum().optional(),
- limit: Joi.number().optional(),
- }).optional(),
- }))
+ return auth.joiValidator.body(
+ Joi.object({
+ endpoint: Joi.object({
+ datasourceId: Joi.string().required(),
+ operation: Joi.string()
+ .required()
+ .valid(...Object.values(DataSourceOperation)),
+ entityId: Joi.string().required(),
+ }).required(),
+ resource: Joi.object({
+ fields: Joi.array().items(Joi.string()).optional(),
+ }).optional(),
+ body: Joi.object().optional(),
+ sort: Joi.object().optional(),
+ filters: filterObject().optional(),
+ paginate: Joi.object({
+ page: Joi.string().alphanum().optional(),
+ limit: Joi.number().optional(),
+ }).optional(),
+ })
+ )
}
export function webhookValidator() {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- live: Joi.bool(),
- _id: OPTIONAL_STRING,
- _rev: OPTIONAL_STRING,
- name: Joi.string().required(),
- bodySchema: Joi.object().optional(),
- action: Joi.object({
- type: Joi.string().required().valid(WebhookActionType.AUTOMATION),
- target: Joi.string().required(),
- }).required(),
- }).unknown(true))
+ return auth.joiValidator.body(
+ Joi.object({
+ live: Joi.bool(),
+ _id: OPTIONAL_STRING,
+ _rev: OPTIONAL_STRING,
+ name: Joi.string().required(),
+ bodySchema: Joi.object().optional(),
+ action: Joi.object({
+ type: Joi.string().required().valid(WebhookActionType.AUTOMATION),
+ target: Joi.string().required(),
+ }).required(),
+ }).unknown(true)
+ )
}
export function roleValidator() {
const permLevelArray = Object.values(permissions.PermissionLevel)
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- _id: OPTIONAL_STRING,
- _rev: OPTIONAL_STRING,
- name: Joi.string().regex(/^[a-zA-Z0-9_]*$/).required(),
- // this is the base permission ID (for now a built in)
- permissionId: Joi.string().valid(...Object.values(permissions.BuiltinPermissionID)).required(),
- permissions: Joi.object()
- .pattern(/.*/, [Joi.string().valid(...permLevelArray)])
- .optional(),
- inherits: OPTIONAL_STRING,
- }).unknown(true))
+
+ return auth.joiValidator.body(
+ Joi.object({
+ _id: OPTIONAL_STRING,
+ _rev: OPTIONAL_STRING,
+ name: Joi.string()
+ .regex(/^[a-zA-Z0-9_]*$/)
+ .required(),
+ // this is the base permission ID (for now a built in)
+ permissionId: Joi.string()
+ .valid(...Object.values(permissions.BuiltinPermissionID))
+ .required(),
+ permissions: Joi.object()
+ .pattern(/.*/, [Joi.string().valid(...permLevelArray)])
+ .optional(),
+ inherits: OPTIONAL_STRING,
+ }).unknown(true)
+ )
}
export function permissionValidator() {
const permLevelArray = Object.values(permissions.PermissionLevel)
- // prettier-ignore
- return auth.joiValidator.params(Joi.object({
- level: Joi.string().valid(...permLevelArray).required(),
- resourceId: Joi.string(),
- roleId: Joi.string(),
- }).unknown(true))
+
+ return auth.joiValidator.params(
+ Joi.object({
+ level: Joi.string()
+ .valid(...permLevelArray)
+ .required(),
+ resourceId: Joi.string(),
+ roleId: Joi.string(),
+ }).unknown(true)
+ )
}
export function screenValidator() {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- name: Joi.string().required(),
- showNavigation: OPTIONAL_BOOLEAN,
- width: OPTIONAL_STRING,
- routing: Joi.object({
- route: Joi.string().required(),
- roleId: Joi.string().required().allow(""),
- homeScreen: OPTIONAL_BOOLEAN,
- }).required().unknown(true),
- props: Joi.object({
- _id: Joi.string().required(),
- _component: Joi.string().required(),
- _children: Joi.array().required(),
- _styles: Joi.object().required(),
- type: OPTIONAL_STRING,
- table: OPTIONAL_STRING,
- layoutId: OPTIONAL_STRING,
- }).required().unknown(true),
- }).unknown(true))
+ return auth.joiValidator.body(
+ Joi.object({
+ name: Joi.string().required(),
+ showNavigation: OPTIONAL_BOOLEAN,
+ width: OPTIONAL_STRING,
+ routing: Joi.object({
+ route: Joi.string().required(),
+ roleId: Joi.string().required().allow(""),
+ homeScreen: OPTIONAL_BOOLEAN,
+ })
+ .required()
+ .unknown(true),
+ props: Joi.object({
+ _id: Joi.string().required(),
+ _component: Joi.string().required(),
+ _children: Joi.array().required(),
+ _styles: Joi.object().required(),
+ type: OPTIONAL_STRING,
+ table: OPTIONAL_STRING,
+ layoutId: OPTIONAL_STRING,
+ })
+ .required()
+ .unknown(true),
+ }).unknown(true)
+ )
}
function generateStepSchema(allowStepTypes: string[]) {
- // prettier-ignore
return Joi.object({
stepId: Joi.string().required(),
id: Joi.string().required(),
@@ -189,33 +247,39 @@ function generateStepSchema(allowStepTypes: string[]) {
icon: Joi.string().required(),
params: Joi.object(),
args: Joi.object(),
- type: Joi.string().required().valid(...allowStepTypes),
+ type: Joi.string()
+ .required()
+ .valid(...allowStepTypes),
}).unknown(true)
}
export function automationValidator(existing = false) {
- // prettier-ignore
- return auth.joiValidator.body(Joi.object({
- _id: existing ? Joi.string().required() : OPTIONAL_STRING,
- _rev: existing ? Joi.string().required() : OPTIONAL_STRING,
- name: Joi.string().required(),
- type: Joi.string().valid("automation").required(),
- definition: Joi.object({
- steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])),
- trigger: generateStepSchema(["TRIGGER"]).allow(null),
- }).required().unknown(true),
- }).unknown(true))
+ return auth.joiValidator.body(
+ Joi.object({
+ _id: existing ? Joi.string().required() : OPTIONAL_STRING,
+ _rev: existing ? Joi.string().required() : OPTIONAL_STRING,
+ name: Joi.string().required(),
+ type: Joi.string().valid("automation").required(),
+ definition: Joi.object({
+ steps: Joi.array()
+ .required()
+ .items(generateStepSchema(["ACTION", "LOGIC"])),
+ trigger: generateStepSchema(["TRIGGER"]).allow(null),
+ })
+ .required()
+ .unknown(true),
+ }).unknown(true)
+ )
}
export function applicationValidator(opts = { isCreate: true }) {
- // prettier-ignore
const base: any = {
_id: OPTIONAL_STRING,
_rev: OPTIONAL_STRING,
url: OPTIONAL_STRING,
template: Joi.object({
templateString: OPTIONAL_STRING,
- })
+ }),
}
const appNameValidator = Joi.string()
diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts
index 18ab94be21..6cdd38bf43 100644
--- a/packages/server/src/sdk/app/views/index.ts
+++ b/packages/server/src/sdk/app/views/index.ts
@@ -39,9 +39,7 @@ async function guardViewSchema(
tableId: string,
viewSchema?: Record
) {
- if (!viewSchema || !Object.keys(viewSchema).length) {
- return
- }
+ viewSchema ??= {}
const table = await sdk.tables.getTable(tableId)
for (const field of Object.keys(viewSchema)) {
@@ -54,17 +52,13 @@ async function guardViewSchema(
}
if (viewSchema[field].readonly) {
- if (!(await features.isViewReadonlyColumnsEnabled())) {
+ if (
+ !(await features.isViewReadonlyColumnsEnabled()) &&
+ !(tableSchemaField as ViewUIFieldMetadata).readonly
+ ) {
throw new HTTPError(`Readonly fields are not enabled`, 400)
}
- if (isRequired(tableSchemaField.constraints)) {
- throw new HTTPError(
- `Field "${field}" cannot be readonly as it is a required field`,
- 400
- )
- }
-
if (!viewSchema[field].visible) {
throw new HTTPError(
`Field "${field}" must be visible if you want to make it readonly`,
@@ -73,6 +67,20 @@ async function guardViewSchema(
}
}
}
+
+ for (const field of Object.values(table.schema)) {
+ if (!isRequired(field.constraints)) {
+ continue
+ }
+
+ const viewSchemaField = viewSchema[field.name]
+ if (viewSchemaField?.readonly) {
+ throw new HTTPError(
+ `You can't make field "${field.name}" readonly because it is a required field.`,
+ 400
+ )
+ }
+ }
}
export async function create(