diff --git a/lerna.json b/lerna.json
index a943000cea..db0a1d59fa 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,5 +1,5 @@
{
- "version": "2.27.2",
+ "version": "2.27.3",
"npmClient": "yarn",
"packages": [
"packages/*",
diff --git a/packages/backend-core/src/docIds/ids.ts b/packages/backend-core/src/docIds/ids.ts
index 9627b2b94c..a828c1b91e 100644
--- a/packages/backend-core/src/docIds/ids.ts
+++ b/packages/backend-core/src/docIds/ids.ts
@@ -18,6 +18,14 @@ export const generateAppID = (tenantId?: string | null) => {
return `${id}${newid()}`
}
+/**
+ * Generates a new table ID.
+ * @returns The new table ID which the table doc can be stored under.
+ */
+export function generateTableID() {
+ return `${DocumentType.TABLE}${SEPARATOR}${newid()}`
+}
+
/**
* Gets a new row ID for the specified table.
* @param tableId The table which the row is being created for.
diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js
index 1db4a773ba..0f912a7161 100644
--- a/packages/bbui/src/helpers.js
+++ b/packages/bbui/src/helpers.js
@@ -168,7 +168,12 @@ export const stringifyDate = (
// Ensure we use the correct offset for the date
const referenceDate = value.toDate()
const offset = referenceDate.getTimezoneOffset() * 60000
- return new Date(value.valueOf() - offset).toISOString().slice(0, -1)
+ const date = new Date(value.valueOf() - offset)
+ if (timeOnly) {
+ // Extract HH:mm
+ return date.toISOString().slice(11, 16)
+ }
+ return date.toISOString().slice(0, -1)
}
// For date-only fields, construct a manual timestamp string without a time
diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte
index e88c28a9d9..8583dbcab7 100644
--- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte
+++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte
@@ -586,13 +586,17 @@
bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors}
/>
- {:else if editableColumn.type === FieldType.DATETIME && !editableColumn.autocolumn}
+ {:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn}
diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts
index b1c02b1764..b8221d208d 100644
--- a/packages/server/src/db/utils.ts
+++ b/packages/server/src/db/utils.ts
@@ -76,7 +76,7 @@ export function getTableParams(tableId?: Optional, otherProps = {}) {
* @returns The new table ID which the table doc can be stored under.
*/
export function generateTableID() {
- return `${DocumentType.TABLE}${SEPARATOR}${newid()}`
+ return dbCore.generateTableID()
}
/**
diff --git a/packages/server/src/integration-test/mysql.spec.ts b/packages/server/src/integration-test/mysql.spec.ts
index b4eb1035d6..8cf4fb8212 100644
--- a/packages/server/src/integration-test/mysql.spec.ts
+++ b/packages/server/src/integration-test/mysql.spec.ts
@@ -281,4 +281,40 @@ describe("mysql integrations", () => {
])
})
})
+
+ describe("POST /api/datasources/:datasourceId/schema", () => {
+ let tableName: string
+
+ beforeEach(async () => {
+ tableName = uniqueTableName()
+ })
+
+ afterEach(async () => {
+ await rawQuery(rawDatasource, `DROP TABLE IF EXISTS \`${tableName}\``)
+ })
+
+ it("recognises enum columns as options", async () => {
+ const enumColumnName = "status"
+
+ const createTableQuery = `
+ CREATE TABLE \`${tableName}\` (
+ \`order_id\` INT AUTO_INCREMENT PRIMARY KEY,
+ \`customer_name\` VARCHAR(100) NOT NULL,
+ \`${enumColumnName}\` ENUM('pending', 'processing', 'shipped', 'delivered', 'cancelled')
+ );
+ `
+
+ await rawQuery(rawDatasource, createTableQuery)
+
+ const response = await makeRequest(
+ "post",
+ `/api/datasources/${datasource._id}/schema`
+ )
+
+ const table = response.body.datasource.entities[tableName]
+
+ expect(table).toBeDefined()
+ expect(table.schema[enumColumnName].type).toEqual(FieldType.OPTIONS)
+ })
+ })
})
diff --git a/packages/server/src/integration-test/postgres.spec.ts b/packages/server/src/integration-test/postgres.spec.ts
index ec4cb90a86..ccf63d0820 100644
--- a/packages/server/src/integration-test/postgres.spec.ts
+++ b/packages/server/src/integration-test/postgres.spec.ts
@@ -1122,6 +1122,37 @@ describe("postgres integrations", () => {
[tableName]: "Table contains invalid columns.",
})
})
+
+ it("recognises enum columns as options", async () => {
+ const tableName = `orders_${generator
+ .guid()
+ .replaceAll("-", "")
+ .substring(0, 6)}`
+ const enumColumnName = "status"
+
+ await rawQuery(
+ rawDatasource,
+ `
+ CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');
+
+ CREATE TABLE ${tableName} (
+ order_id SERIAL PRIMARY KEY,
+ customer_name VARCHAR(100) NOT NULL,
+ ${enumColumnName} order_status
+ );
+ `
+ )
+
+ const response = await makeRequest(
+ "post",
+ `/api/datasources/${datasource._id}/schema`
+ )
+
+ const table = response.body.datasource.entities[tableName]
+
+ expect(table).toBeDefined()
+ expect(table.schema[enumColumnName].type).toEqual(FieldType.OPTIONS)
+ })
})
describe("Integration compatibility with postgres search_path", () => {
diff --git a/packages/server/src/integrations/postgres.ts b/packages/server/src/integrations/postgres.ts
index 3711db6950..3652864991 100644
--- a/packages/server/src/integrations/postgres.ts
+++ b/packages/server/src/integrations/postgres.ts
@@ -329,14 +329,12 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
// Fetch enum values
const enumsResponse = await this.client.query(this.ENUM_VALUES())
+ // output array, allows for more than 1 single-select to be used at a time
const enumValues = enumsResponse.rows?.reduce((acc, row) => {
- if (!acc[row.typname]) {
- return {
- [row.typname]: [row.enumlabel],
- }
+ return {
+ ...acc,
+ [row.typname]: [...(acc[row.typname] || []), row.enumlabel],
}
- acc[row.typname].push(row.enumlabel)
- return acc
}, {})
for (let column of columnsResponse.rows) {
diff --git a/packages/server/src/integrations/utils/utils.ts b/packages/server/src/integrations/utils/utils.ts
index 7db0ce9784..44cdd006da 100644
--- a/packages/server/src/integrations/utils/utils.ts
+++ b/packages/server/src/integrations/utils/utils.ts
@@ -95,6 +95,7 @@ const SQL_OPTIONS_TYPE_MAP: Record = {
const SQL_MISC_TYPE_MAP: Record = {
json: FieldType.JSON,
bigint: FieldType.BIGINT,
+ enum: FieldType.OPTIONS,
}
const SQL_TYPE_MAP: Record = {
diff --git a/packages/server/src/sdk/app/rows/tests/utils.spec.ts b/packages/server/src/sdk/app/rows/tests/utils.spec.ts
new file mode 100644
index 0000000000..55cdf9ea20
--- /dev/null
+++ b/packages/server/src/sdk/app/rows/tests/utils.spec.ts
@@ -0,0 +1,335 @@
+import dayjs from "dayjs"
+import {
+ FieldType,
+ INTERNAL_TABLE_SOURCE_ID,
+ Table,
+ TableSourceType,
+} from "@budibase/types"
+import { generateTableID } from "../../../../db/utils"
+import { validate } from "../utils"
+import { generator } from "@budibase/backend-core/tests"
+
+describe("validate", () => {
+ const hour = () => generator.hour().toString().padStart(2, "0")
+ const minute = () => generator.minute().toString().padStart(2, "0")
+ const second = minute
+
+ describe("time only", () => {
+ const getTable = (): Table => ({
+ type: "table",
+ _id: generateTableID(),
+ name: "table",
+ sourceId: INTERNAL_TABLE_SOURCE_ID,
+ sourceType: TableSourceType.INTERNAL,
+ schema: {
+ time: {
+ name: "time",
+ type: FieldType.DATETIME,
+ timeOnly: true,
+ },
+ },
+ })
+
+ it("should accept empty values", async () => {
+ const row = {}
+ const table = getTable()
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ expect(output.errors).toEqual({})
+ })
+
+ it("should accept valid times with HH:mm format", async () => {
+ const row = {
+ time: `${hour()}:${minute()}`,
+ }
+ const table = getTable()
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ })
+
+ it("should accept valid times with HH:mm:ss format", async () => {
+ const row = {
+ time: `${hour()}:${minute()}:${second()}`,
+ }
+ const table = getTable()
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ })
+
+ it.each([
+ ["ISO datetimes", generator.date().toISOString()],
+ ["random values", generator.word()],
+ ])("should reject %s", async (_, time) => {
+ const row = {
+ time,
+ }
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({ time: ['"time" is not a valid time'] })
+ })
+
+ describe("time constraints", () => {
+ describe("earliest only", () => {
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ datetime: {
+ earliest: "10:00",
+ latest: "",
+ },
+ }
+
+ it.each([
+ "10:00",
+ "15:00",
+ `10:${minute()}`,
+ "12:34",
+ `${generator.integer({ min: 11, max: 23 })}:${minute()}`,
+ ])("should accept values after config value (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ })
+
+ it.each([
+ "09:59:59",
+ `${generator.integer({ min: 0, max: 9 })}:${minute()}`,
+ ])("should reject values before config value (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no earlier than 10:00"],
+ })
+ })
+ })
+
+ describe("latest only", () => {
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ datetime: {
+ earliest: "",
+ latest: "15:16:17",
+ },
+ }
+
+ it.each([
+ "15:16:17",
+ "15:16",
+ "15:00",
+ `${generator.integer({ min: 0, max: 12 })}:${minute()}`,
+ ])("should accept values before config value (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ })
+
+ it.each([
+ "15:16:18",
+ `${generator.integer({ min: 16, max: 23 })}:${minute()}`,
+ ])("should reject values after config value (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no later than 15:16:17"],
+ })
+ })
+ })
+
+ describe("range", () => {
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ datetime: {
+ earliest: "10:00",
+ latest: "15:00",
+ },
+ }
+
+ it.each(["10:00", "15:00", `10:${minute()}`, "12:34"])(
+ "should accept values in range (%s)",
+ async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ }
+ )
+
+ it.each([
+ "9:59:50",
+ `${generator.integer({ min: 0, max: 9 })}:${minute()}`,
+ ])("should reject values before range (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no earlier than 10:00"],
+ })
+ })
+
+ it.each([
+ "15:00:01",
+ `${generator.integer({ min: 16, max: 23 })}:${minute()}`,
+ ])("should reject values after range (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no later than 15:00"],
+ })
+ })
+
+ describe("range crossing midnight", () => {
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ datetime: {
+ earliest: "15:00",
+ latest: "10:00",
+ },
+ }
+
+ it.each(["10:00", "15:00", `9:${minute()}`, "16:34", "00:00"])(
+ "should accept values in range (%s)",
+ async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ }
+ )
+
+ it.each(["10:01", "14:59:59", `12:${minute()}`])(
+ "should reject values out range (%s)",
+ async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no later than 10:00"],
+ })
+ }
+ )
+ })
+ })
+ })
+
+ describe("required", () => {
+ it("should reject empty values", async () => {
+ const row = {}
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({ time: ["can't be blank"] })
+ })
+
+ it.each([undefined, null])("should reject %s values", async time => {
+ const row = { time }
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({ time: ["can't be blank"] })
+ })
+ })
+
+ describe("range", () => {
+ const table = getTable()
+ table.schema.time.constraints = {
+ presence: true,
+ datetime: {
+ earliest: "10:00",
+ latest: "15:00",
+ },
+ }
+
+ it.each(["10:00", "15:00", `10:${minute()}`, "12:34"])(
+ "should accept values in range (%s)",
+ async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ }
+ )
+
+ it.each([
+ "9:59:50",
+ `${generator.integer({ min: 0, max: 9 })}:${minute()}`,
+ ])("should reject values before range (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no earlier than 10:00"],
+ })
+ })
+
+ it.each([
+ "15:00:01",
+ `${generator.integer({ min: 16, max: 23 })}:${minute()}`,
+ ])("should reject values after range (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no later than 15:00"],
+ })
+ })
+
+ describe("datetime ISO configs", () => {
+ const table = getTable()
+
+ table.schema.time.constraints = {
+ presence: true,
+ datetime: {
+ earliest: dayjs().hour(10).minute(0).second(0).toISOString(),
+ latest: dayjs().hour(15).minute(0).second(0).toISOString(),
+ },
+ }
+
+ it.each(["10:00", "15:00", `12:${minute()}`])(
+ "should accept values in range (%s)",
+ async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(true)
+ }
+ )
+
+ it.each([
+ "09:59:50",
+ `${generator.integer({ min: 0, max: 9 })}:${minute()}`,
+ ])("should reject values before range (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no earlier than 10:00"],
+ })
+ })
+
+ it.each([
+ "15:00:01",
+ `${generator.integer({ min: 16, max: 23 })}:${minute()}`,
+ ])("should reject values after range (%s)", async time => {
+ const row = { time }
+ const output = await validate({ table, tableId: table._id!, row })
+ expect(output.valid).toBe(false)
+ expect(output.errors).toEqual({
+ time: ["must be no later than 15:00"],
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/packages/server/src/sdk/app/rows/utils.ts b/packages/server/src/sdk/app/rows/utils.ts
index f79e86fc85..bb37fd99f3 100644
--- a/packages/server/src/sdk/app/rows/utils.ts
+++ b/packages/server/src/sdk/app/rows/utils.ts
@@ -1,8 +1,10 @@
-import cloneDeep from "lodash/cloneDeep"
import validateJs from "validate.js"
+import dayjs from "dayjs"
+import cloneDeep from "lodash/fp/cloneDeep"
import {
Datasource,
DatasourcePlusQueryResponse,
+ FieldConstraints,
FieldType,
QueryJson,
Row,
@@ -206,6 +208,8 @@ export async function validate({
} catch (err) {
errors[fieldName] = [`Contains invalid JSON`]
}
+ } else if (type === FieldType.DATETIME && column.timeOnly) {
+ res = validateTimeOnlyField(fieldName, row[fieldName], constraints)
} else {
res = validateJs.single(row[fieldName], constraints)
}
@@ -213,3 +217,86 @@ export async function validate({
}
return { valid: Object.keys(errors).length === 0, errors }
}
+
+function validateTimeOnlyField(
+ fieldName: string,
+ value: any,
+ constraints: FieldConstraints | undefined
+) {
+ let res
+ if (value && !value.match(/^(\d+)(:[0-5]\d){1,2}$/)) {
+ res = [`"${fieldName}" is not a valid time`]
+ } else if (constraints) {
+ let castedValue = value
+ const stringTimeToDate = (value: string) => {
+ const [hour, minute, second] = value.split(":").map((x: string) => +x)
+ let date = dayjs("2000-01-01T00:00:00.000Z").hour(hour).minute(minute)
+ if (!isNaN(second)) {
+ date = date.second(second)
+ }
+ return date
+ }
+
+ if (castedValue) {
+ castedValue = stringTimeToDate(castedValue)
+ }
+ let castedConstraints = cloneDeep(constraints)
+
+ let earliest, latest
+ let easliestTimeString: string, latestTimeString: string
+ if (castedConstraints.datetime?.earliest) {
+ easliestTimeString = castedConstraints.datetime.earliest
+ if (dayjs(castedConstraints.datetime.earliest).isValid()) {
+ easliestTimeString = dayjs(castedConstraints.datetime.earliest).format(
+ "HH:mm"
+ )
+ }
+ earliest = stringTimeToDate(easliestTimeString)
+ }
+ if (castedConstraints.datetime?.latest) {
+ latestTimeString = castedConstraints.datetime.latest
+ if (dayjs(castedConstraints.datetime.latest).isValid()) {
+ latestTimeString = dayjs(castedConstraints.datetime.latest).format(
+ "HH:mm"
+ )
+ }
+ latest = stringTimeToDate(latestTimeString)
+ }
+
+ if (earliest && latest && earliest.isAfter(latest)) {
+ latest = latest.add(1, "day")
+ if (earliest.isAfter(castedValue)) {
+ castedValue = castedValue.add(1, "day")
+ }
+ }
+
+ if (earliest || latest) {
+ castedConstraints.datetime = {
+ earliest: earliest?.toISOString() || "",
+ latest: latest?.toISOString() || "",
+ }
+ }
+
+ let jsValidation = validateJs.single(
+ castedValue?.toISOString(),
+ castedConstraints
+ )
+ jsValidation = jsValidation?.map((m: string) =>
+ m
+ ?.replace(
+ castedConstraints.datetime?.earliest || "",
+ easliestTimeString || ""
+ )
+ .replace(
+ castedConstraints.datetime?.latest || "",
+ latestTimeString || ""
+ )
+ )
+ if (jsValidation) {
+ res ??= []
+ res.push(...jsValidation)
+ }
+ }
+
+ return res
+}
diff --git a/packages/server/src/sdk/app/tables/external/index.ts b/packages/server/src/sdk/app/tables/external/index.ts
index 98e6e561c8..842b6b5648 100644
--- a/packages/server/src/sdk/app/tables/external/index.ts
+++ b/packages/server/src/sdk/app/tables/external/index.ts
@@ -73,6 +73,16 @@ function validate(table: Table, oldTable?: Table) {
`Column "${key}" has subtype "${column.subtype}" - this is not supported.`
)
}
+
+ if (column.type === FieldType.DATETIME) {
+ const oldColumn = oldTable?.schema[key] as typeof column
+
+ if (oldColumn && column.timeOnly !== oldColumn.timeOnly) {
+ throw new Error(
+ `Column "${key}" can not change from time to datetime or viceversa.`
+ )
+ }
+ }
}
}