diff --git a/packages/backend-core/src/sql/utils.ts b/packages/backend-core/src/sql/utils.ts index e73c6ac445..67b5d2081b 100644 --- a/packages/backend-core/src/sql/utils.ts +++ b/packages/backend-core/src/sql/utils.ts @@ -8,6 +8,7 @@ const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` const ROW_ID_REGEX = /^\[.*]$/g const ENCODED_SPACE = encodeURIComponent(" ") const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/ +const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/ export function isExternalTableID(tableId: string) { return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR) @@ -147,6 +148,10 @@ export function isValidFilter(value: any) { return value != null && value !== "" } +export function isValidTime(value: string) { + return TIME_REGEX.test(value) +} + export function sqlLog(client: string, query: string, values?: any[]) { if (!environment.SQL_LOGGING_ENABLE) { return diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 27a0d0983e..5ce16a54d5 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -550,6 +550,242 @@ describe.each([ expect(row.name).toEqual(`{ "foo": "2023-01-26T11:48:57.000Z" }`) }) + + describe("default values", () => { + let table: Table + + describe("string column", () => { + beforeAll(async () => { + table = await config.api.table.save( + saveTableRequest({ + schema: { + description: { + name: "description", + type: FieldType.STRING, + default: "default description", + constraints: { + presence: true, + }, + }, + }, + }) + ) + }) + + it("creates a new row with a default value successfully", async () => { + const row = await config.api.row.save(table._id!, {}) + expect(row.description).toEqual("default description") + }) + + it("does not use default value if value specified", async () => { + const row = await config.api.row.save(table._id!, { + description: "specified description", + }) + expect(row.description).toEqual("specified description") + }) + + it("uses the default value if value is null", async () => { + const row = await config.api.row.save(table._id!, { + description: null, + }) + expect(row.description).toEqual("default description") + }) + + it("uses the default value if value is undefined", async () => { + const row = await config.api.row.save(table._id!, { + description: undefined, + }) + expect(row.description).toEqual("default description") + }) + }) + + describe("number column", () => { + beforeAll(async () => { + table = await config.api.table.save( + saveTableRequest({ + schema: { + age: { + name: "age", + type: FieldType.NUMBER, + default: "25", + }, + }, + }) + ) + }) + + it("creates a new row with a default value successfully", async () => { + const row = await config.api.row.save(table._id!, {}) + expect(row.age).toEqual(25) + }) + + it("does not use default value if value specified", async () => { + const row = await config.api.row.save(table._id!, { + age: 30, + }) + expect(row.age).toEqual(30) + }) + }) + + describe("date column", () => { + it("creates a row with a default value successfully", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + date: { + name: "date", + type: FieldType.DATETIME, + default: "2023-01-26T11:48:57.000Z", + }, + }, + }) + ) + const row = await config.api.row.save(table._id!, {}) + expect(row.date).toEqual("2023-01-26T11:48:57.000Z") + }) + + it("gives an error if the default value is invalid", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + date: { + name: "date", + type: FieldType.DATETIME, + default: "invalid", + }, + }, + }) + ) + await config.api.row.save( + table._id!, + {}, + { + status: 400, + body: { + message: `Invalid default value for field 'date' - Invalid date value: "invalid"`, + }, + } + ) + }) + }) + + describe("bindings", () => { + describe("string column", () => { + beforeAll(async () => { + table = await config.api.table.save( + saveTableRequest({ + schema: { + description: { + name: "description", + type: FieldType.STRING, + default: `{{ date now "YYYY-MM-DDTHH:mm:ss" }}`, + }, + }, + }) + ) + }) + + it("can use bindings in default values", async () => { + const row = await config.api.row.save(table._id!, {}) + expect(row.description).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + ) + }) + + it("does not use default value if value specified", async () => { + const row = await config.api.row.save(table._id!, { + description: "specified description", + }) + expect(row.description).toEqual("specified description") + }) + + it("can bind the current user", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + user: { + name: "user", + type: FieldType.STRING, + default: `{{ [Current User]._id }}`, + }, + }, + }) + ) + const row = await config.api.row.save(table._id!, {}) + expect(row.user).toEqual(config.getUser()._id) + }) + + it("cannot access current user password", async () => { + const table = await config.api.table.save( + saveTableRequest({ + schema: { + user: { + name: "user", + type: FieldType.STRING, + default: `{{ user.password }}`, + }, + }, + }) + ) + const row = await config.api.row.save(table._id!, {}) + // For some reason it's null for internal tables, and undefined for + // external. + expect(row.user == null).toBe(true) + }) + }) + + describe("number column", () => { + beforeAll(async () => { + table = await config.api.table.save( + saveTableRequest({ + schema: { + age: { + name: "age", + type: FieldType.NUMBER, + default: `{{ sum 10 10 5 }}`, + }, + }, + }) + ) + }) + + it("can use bindings in default values", async () => { + const row = await config.api.row.save(table._id!, {}) + expect(row.age).toEqual(25) + }) + + describe("invalid default value", () => { + beforeAll(async () => { + table = await config.api.table.save( + saveTableRequest({ + schema: { + age: { + name: "age", + type: FieldType.NUMBER, + default: `{{ capitalize "invalid" }}`, + }, + }, + }) + ) + }) + + it("throws an error when invalid default value", async () => { + await config.api.row.save( + table._id!, + {}, + { + status: 400, + body: { + message: + "Invalid default value for field 'age' - Invalid number value \"Invalid\"", + }, + } + ) + }) + }) + }) + }) + }) }) describe("get", () => { diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index e9853e5dff..3edbc24365 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -1022,6 +1022,11 @@ describe.each([ schema: { one: { type: FieldType.STRING, name: "one" }, two: { type: FieldType.STRING, name: "two" }, + default: { + type: FieldType.STRING, + name: "default", + default: "default", + }, }, }) ) @@ -1042,11 +1047,13 @@ describe.each([ _viewId: view.id, one: "foo", two: "bar", + default: "ohnoes", }) const row = await config.api.row.get(table._id!, newRow._id!) expect(row.one).toBeUndefined() expect(row.two).toEqual("bar") + expect(row.default).toEqual("default") }) it("can't persist readonly columns", async () => { diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index 73176af6d8..71de056814 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -1,14 +1,22 @@ import * as linkRows from "../../db/linkedRows" import { fixAutoColumnSubType, processFormulas } from "./utils" -import { objectStore, utils } from "@budibase/backend-core" +import { + cache, + context, + HTTPError, + objectStore, + utils, +} from "@budibase/backend-core" import { InternalTables } from "../../db/utils" import { TYPE_TRANSFORM_MAP } from "./map" import { AutoFieldSubType, FieldType, + IdentityType, Row, RowAttachment, Table, + User, } from "@budibase/types" import { cloneDeep } from "lodash/fp" import { @@ -19,6 +27,7 @@ import { } from "./bbReferenceProcessor" import { isExternalTableID } from "../../integrations/utils" import { helpers } from "@budibase/shared-core" +import { processString } from "@budibase/string-templates" export * from "./utils" export * from "./attachments" @@ -88,7 +97,34 @@ export async function processAutoColumn( break } } - return { table, row } +} + +async function processDefaultValues(table: Table, row: Row) { + const ctx: { ["Current User"]?: User; user?: User } = {} + + const identity = context.getIdentity() + if (identity?._id && identity.type === IdentityType.USER) { + const user = await cache.user.getUser(identity._id) + delete user.password + + ctx["Current User"] = user + ctx.user = user + } + + for (let [key, schema] of Object.entries(table.schema)) { + if ("default" in schema && schema.default != null && row[key] == null) { + const processed = await processString(schema.default, ctx) + + try { + row[key] = coerce(processed, schema.type) + } catch (err: any) { + throw new HTTPError( + `Invalid default value for field '${key}' - ${err.message}`, + 400 + ) + } + } + } } /** @@ -182,8 +218,10 @@ export async function inputProcessing( clonedRow._rev = row._rev } - // handle auto columns - this returns an object like {table, row} - return processAutoColumn(userId, table, clonedRow, opts) + await processAutoColumn(userId, table, clonedRow, opts) + await processDefaultValues(table, clonedRow) + + return { table, row: clonedRow } } /** diff --git a/packages/server/src/utilities/rowProcessor/map.ts b/packages/server/src/utilities/rowProcessor/map.ts index ccaf07ee96..db52c4718c 100644 --- a/packages/server/src/utilities/rowProcessor/map.ts +++ b/packages/server/src/utilities/rowProcessor/map.ts @@ -1,3 +1,4 @@ +import { sql } from "@budibase/backend-core" import { FieldType } from "@budibase/types" const parseArrayString = (value: any) => { @@ -91,7 +92,13 @@ export const TYPE_TRANSFORM_MAP: any = { [null]: null, //@ts-ignore [undefined]: undefined, - parse: (n: any) => parseFloat(n), + parse: (n: any) => { + const parsed = parseFloat(n) + if (isNaN(parsed)) { + throw new Error(`Invalid number value "${n}"`) + } + return parsed + }, }, [FieldType.BIGINT]: { "": null, @@ -109,8 +116,15 @@ export const TYPE_TRANSFORM_MAP: any = { parse: (date: any) => { if (date instanceof Date) { return date.toISOString() + } else if (typeof date === "string" && sql.utils.isValidTime(date)) { + return date + } else { + const parsed = new Date(date) + if (isNaN(parsed.getTime())) { + throw new Error(`Invalid date value: "${date}"`) + } + return date } - return date }, }, [FieldType.ATTACHMENTS]: { diff --git a/packages/types/src/documents/app/table/schema.ts b/packages/types/src/documents/app/table/schema.ts index 38424b26b6..3a2ddf097f 100644 --- a/packages/types/src/documents/app/table/schema.ts +++ b/packages/types/src/documents/app/table/schema.ts @@ -81,11 +81,13 @@ export interface NumberFieldMetadata extends Omit { toTable: string toKey: string } + default?: string } export interface JsonFieldMetadata extends Omit { type: FieldType.JSON subtype?: JsonFieldSubType.ARRAY + default?: string } export interface DateFieldMetadata extends Omit { @@ -94,17 +96,25 @@ export interface DateFieldMetadata extends Omit { timeOnly?: boolean dateOnly?: boolean subtype?: AutoFieldSubType.CREATED_AT | AutoFieldSubType.UPDATED_AT + default?: string } export interface LongFormFieldMetadata extends BaseFieldSchema { type: FieldType.LONGFORM useRichText?: boolean | null + default?: string +} + +export interface StringFieldMetadata extends BaseFieldSchema { + type: FieldType.STRING + default?: string } export interface FormulaFieldMetadata extends BaseFieldSchema { type: FieldType.FORMULA formula: string formulaType?: FormulaType + default?: string } export interface BBReferenceFieldMetadata @@ -171,6 +181,7 @@ interface OtherFieldMetadata extends BaseFieldSchema { | FieldType.BB_REFERENCE | FieldType.BB_REFERENCE_SINGLE | FieldType.ATTACHMENTS + | FieldType.STRING > } @@ -182,6 +193,7 @@ export type FieldSchema = | FormulaFieldMetadata | NumberFieldMetadata | LongFormFieldMetadata + | StringFieldMetadata | BBReferenceFieldMetadata | JsonFieldMetadata | AttachmentFieldMetadata