Merge pull request #14177 from Budibase/budi-8434-default-value-row-processing
[BUDI-8434] Default value implementation on the backend.
This commit is contained in:
commit
2d976bd439
|
@ -8,6 +8,7 @@ const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
|
||||||
const ROW_ID_REGEX = /^\[.*]$/g
|
const ROW_ID_REGEX = /^\[.*]$/g
|
||||||
const ENCODED_SPACE = encodeURIComponent(" ")
|
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 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) {
|
export function isExternalTableID(tableId: string) {
|
||||||
return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR)
|
return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR)
|
||||||
|
@ -147,6 +148,10 @@ export function isValidFilter(value: any) {
|
||||||
return value != null && value !== ""
|
return value != null && value !== ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidTime(value: string) {
|
||||||
|
return TIME_REGEX.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
export function sqlLog(client: string, query: string, values?: any[]) {
|
export function sqlLog(client: string, query: string, values?: any[]) {
|
||||||
if (!environment.SQL_LOGGING_ENABLE) {
|
if (!environment.SQL_LOGGING_ENABLE) {
|
||||||
return
|
return
|
||||||
|
|
|
@ -550,6 +550,242 @@ describe.each([
|
||||||
|
|
||||||
expect(row.name).toEqual(`{ "foo": "2023-01-26T11:48:57.000Z" }`)
|
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", () => {
|
describe("get", () => {
|
||||||
|
|
|
@ -1022,6 +1022,11 @@ describe.each([
|
||||||
schema: {
|
schema: {
|
||||||
one: { type: FieldType.STRING, name: "one" },
|
one: { type: FieldType.STRING, name: "one" },
|
||||||
two: { type: FieldType.STRING, name: "two" },
|
two: { type: FieldType.STRING, name: "two" },
|
||||||
|
default: {
|
||||||
|
type: FieldType.STRING,
|
||||||
|
name: "default",
|
||||||
|
default: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -1042,11 +1047,13 @@ describe.each([
|
||||||
_viewId: view.id,
|
_viewId: view.id,
|
||||||
one: "foo",
|
one: "foo",
|
||||||
two: "bar",
|
two: "bar",
|
||||||
|
default: "ohnoes",
|
||||||
})
|
})
|
||||||
|
|
||||||
const row = await config.api.row.get(table._id!, newRow._id!)
|
const row = await config.api.row.get(table._id!, newRow._id!)
|
||||||
expect(row.one).toBeUndefined()
|
expect(row.one).toBeUndefined()
|
||||||
expect(row.two).toEqual("bar")
|
expect(row.two).toEqual("bar")
|
||||||
|
expect(row.default).toEqual("default")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("can't persist readonly columns", async () => {
|
it("can't persist readonly columns", async () => {
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
import * as linkRows from "../../db/linkedRows"
|
import * as linkRows from "../../db/linkedRows"
|
||||||
import { fixAutoColumnSubType, processFormulas } from "./utils"
|
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 { InternalTables } from "../../db/utils"
|
||||||
import { TYPE_TRANSFORM_MAP } from "./map"
|
import { TYPE_TRANSFORM_MAP } from "./map"
|
||||||
import {
|
import {
|
||||||
AutoFieldSubType,
|
AutoFieldSubType,
|
||||||
FieldType,
|
FieldType,
|
||||||
|
IdentityType,
|
||||||
Row,
|
Row,
|
||||||
RowAttachment,
|
RowAttachment,
|
||||||
Table,
|
Table,
|
||||||
|
User,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import {
|
import {
|
||||||
|
@ -19,6 +27,7 @@ import {
|
||||||
} from "./bbReferenceProcessor"
|
} from "./bbReferenceProcessor"
|
||||||
import { isExternalTableID } from "../../integrations/utils"
|
import { isExternalTableID } from "../../integrations/utils"
|
||||||
import { helpers } from "@budibase/shared-core"
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
import { processString } from "@budibase/string-templates"
|
||||||
|
|
||||||
export * from "./utils"
|
export * from "./utils"
|
||||||
export * from "./attachments"
|
export * from "./attachments"
|
||||||
|
@ -88,7 +97,34 @@ export async function processAutoColumn(
|
||||||
break
|
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
|
clonedRow._rev = row._rev
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle auto columns - this returns an object like {table, row}
|
await processAutoColumn(userId, table, clonedRow, opts)
|
||||||
return processAutoColumn(userId, table, clonedRow, opts)
|
await processDefaultValues(table, clonedRow)
|
||||||
|
|
||||||
|
return { table, row: clonedRow }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { sql } from "@budibase/backend-core"
|
||||||
import { FieldType } from "@budibase/types"
|
import { FieldType } from "@budibase/types"
|
||||||
|
|
||||||
const parseArrayString = (value: any) => {
|
const parseArrayString = (value: any) => {
|
||||||
|
@ -91,7 +92,13 @@ export const TYPE_TRANSFORM_MAP: any = {
|
||||||
[null]: null,
|
[null]: null,
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
[undefined]: undefined,
|
[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]: {
|
[FieldType.BIGINT]: {
|
||||||
"": null,
|
"": null,
|
||||||
|
@ -109,8 +116,15 @@ export const TYPE_TRANSFORM_MAP: any = {
|
||||||
parse: (date: any) => {
|
parse: (date: any) => {
|
||||||
if (date instanceof Date) {
|
if (date instanceof Date) {
|
||||||
return date.toISOString()
|
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]: {
|
[FieldType.ATTACHMENTS]: {
|
||||||
|
|
|
@ -81,11 +81,13 @@ export interface NumberFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
toTable: string
|
toTable: string
|
||||||
toKey: string
|
toKey: string
|
||||||
}
|
}
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JsonFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
export interface JsonFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
type: FieldType.JSON
|
type: FieldType.JSON
|
||||||
subtype?: JsonFieldSubType.ARRAY
|
subtype?: JsonFieldSubType.ARRAY
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
|
@ -94,17 +96,25 @@ export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
timeOnly?: boolean
|
timeOnly?: boolean
|
||||||
dateOnly?: boolean
|
dateOnly?: boolean
|
||||||
subtype?: AutoFieldSubType.CREATED_AT | AutoFieldSubType.UPDATED_AT
|
subtype?: AutoFieldSubType.CREATED_AT | AutoFieldSubType.UPDATED_AT
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LongFormFieldMetadata extends BaseFieldSchema {
|
export interface LongFormFieldMetadata extends BaseFieldSchema {
|
||||||
type: FieldType.LONGFORM
|
type: FieldType.LONGFORM
|
||||||
useRichText?: boolean | null
|
useRichText?: boolean | null
|
||||||
|
default?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringFieldMetadata extends BaseFieldSchema {
|
||||||
|
type: FieldType.STRING
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormulaFieldMetadata extends BaseFieldSchema {
|
export interface FormulaFieldMetadata extends BaseFieldSchema {
|
||||||
type: FieldType.FORMULA
|
type: FieldType.FORMULA
|
||||||
formula: string
|
formula: string
|
||||||
formulaType?: FormulaType
|
formulaType?: FormulaType
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BBReferenceFieldMetadata
|
export interface BBReferenceFieldMetadata
|
||||||
|
@ -171,6 +181,7 @@ interface OtherFieldMetadata extends BaseFieldSchema {
|
||||||
| FieldType.BB_REFERENCE
|
| FieldType.BB_REFERENCE
|
||||||
| FieldType.BB_REFERENCE_SINGLE
|
| FieldType.BB_REFERENCE_SINGLE
|
||||||
| FieldType.ATTACHMENTS
|
| FieldType.ATTACHMENTS
|
||||||
|
| FieldType.STRING
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,6 +193,7 @@ export type FieldSchema =
|
||||||
| FormulaFieldMetadata
|
| FormulaFieldMetadata
|
||||||
| NumberFieldMetadata
|
| NumberFieldMetadata
|
||||||
| LongFormFieldMetadata
|
| LongFormFieldMetadata
|
||||||
|
| StringFieldMetadata
|
||||||
| BBReferenceFieldMetadata
|
| BBReferenceFieldMetadata
|
||||||
| JsonFieldMetadata
|
| JsonFieldMetadata
|
||||||
| AttachmentFieldMetadata
|
| AttachmentFieldMetadata
|
||||||
|
|
Loading…
Reference in New Issue