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}
- +
@@ -601,30 +605,36 @@
- -
- - {#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly} -
-
- - - - -
-
+ + {#if !editableColumn.timeOnly} + {#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly} +
+
+ + + + +
+ +
+ {/if} + {/if} - {:else if editableColumn.type === FieldType.NUMBER && !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.` + ) + } + } } }