Merge branch 'master' into feature/audit-log-sqs

This commit is contained in:
Michael Drury 2024-05-23 16:52:50 +01:00 committed by GitHub
commit a3d2871330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 554 additions and 33 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.27.2", "version": "2.27.3",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -18,6 +18,14 @@ export const generateAppID = (tenantId?: string | null) => {
return `${id}${newid()}` 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. * Gets a new row ID for the specified table.
* @param tableId The table which the row is being created for. * @param tableId The table which the row is being created for.

View File

@ -168,7 +168,12 @@ export const stringifyDate = (
// Ensure we use the correct offset for the date // Ensure we use the correct offset for the date
const referenceDate = value.toDate() const referenceDate = value.toDate()
const offset = referenceDate.getTimezoneOffset() * 60000 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 // For date-only fields, construct a manual timestamp string without a time

View File

@ -586,13 +586,17 @@
bind:constraints={editableColumn.constraints} bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors} bind:optionColors={editableColumn.optionColors}
/> />
{:else if editableColumn.type === FieldType.DATETIME && !editableColumn.autocolumn} {:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn}
<div class="split-label"> <div class="split-label">
<div class="label-length"> <div class="label-length">
<Label size="M">Earliest</Label> <Label size="M">Earliest</Label>
</div> </div>
<div class="input-length"> <div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.earliest} /> <DatePicker
bind:value={editableColumn.constraints.datetime.earliest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div> </div>
</div> </div>
@ -601,30 +605,36 @@
<Label size="M">Latest</Label> <Label size="M">Latest</Label>
</div> </div>
<div class="input-length"> <div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.latest} /> <DatePicker
</div> bind:value={editableColumn.constraints.datetime.latest}
</div> enableTime={!editableColumn.dateOnly}
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly} timeOnly={editableColumn.timeOnly}
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
type="info"
text={isCreating
? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle
bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones"
/> />
</div> </div>
</div>
{#if !editableColumn.timeOnly}
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
type="info"
text={isCreating
? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle
bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones"
/>
</div>
{/if}
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
{/if} {/if}
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
{:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn} {:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn}
<div class="split-label"> <div class="split-label">
<div class="label-length"> <div class="label-length">

View File

@ -76,7 +76,7 @@ export function getTableParams(tableId?: Optional, otherProps = {}) {
* @returns The new table ID which the table doc can be stored under. * @returns The new table ID which the table doc can be stored under.
*/ */
export function generateTableID() { export function generateTableID() {
return `${DocumentType.TABLE}${SEPARATOR}${newid()}` return dbCore.generateTableID()
} }
/** /**

View File

@ -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)
})
})
}) })

View File

@ -1122,6 +1122,37 @@ describe("postgres integrations", () => {
[tableName]: "Table contains invalid columns.", [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", () => { describe("Integration compatibility with postgres search_path", () => {

View File

@ -329,14 +329,12 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
// Fetch enum values // Fetch enum values
const enumsResponse = await this.client.query(this.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) => { const enumValues = enumsResponse.rows?.reduce((acc, row) => {
if (!acc[row.typname]) { return {
return { ...acc,
[row.typname]: [row.enumlabel], [row.typname]: [...(acc[row.typname] || []), row.enumlabel],
}
} }
acc[row.typname].push(row.enumlabel)
return acc
}, {}) }, {})
for (let column of columnsResponse.rows) { for (let column of columnsResponse.rows) {

View File

@ -95,6 +95,7 @@ const SQL_OPTIONS_TYPE_MAP: Record<string, PrimitiveTypes> = {
const SQL_MISC_TYPE_MAP: Record<string, PrimitiveTypes> = { const SQL_MISC_TYPE_MAP: Record<string, PrimitiveTypes> = {
json: FieldType.JSON, json: FieldType.JSON,
bigint: FieldType.BIGINT, bigint: FieldType.BIGINT,
enum: FieldType.OPTIONS,
} }
const SQL_TYPE_MAP: Record<string, PrimitiveTypes> = { const SQL_TYPE_MAP: Record<string, PrimitiveTypes> = {

View File

@ -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"],
})
})
})
})
})
})

View File

@ -1,8 +1,10 @@
import cloneDeep from "lodash/cloneDeep"
import validateJs from "validate.js" import validateJs from "validate.js"
import dayjs from "dayjs"
import cloneDeep from "lodash/fp/cloneDeep"
import { import {
Datasource, Datasource,
DatasourcePlusQueryResponse, DatasourcePlusQueryResponse,
FieldConstraints,
FieldType, FieldType,
QueryJson, QueryJson,
Row, Row,
@ -206,6 +208,8 @@ export async function validate({
} catch (err) { } catch (err) {
errors[fieldName] = [`Contains invalid JSON`] errors[fieldName] = [`Contains invalid JSON`]
} }
} else if (type === FieldType.DATETIME && column.timeOnly) {
res = validateTimeOnlyField(fieldName, row[fieldName], constraints)
} else { } else {
res = validateJs.single(row[fieldName], constraints) res = validateJs.single(row[fieldName], constraints)
} }
@ -213,3 +217,86 @@ export async function validate({
} }
return { valid: Object.keys(errors).length === 0, errors } 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
}

View File

@ -73,6 +73,16 @@ function validate(table: Table, oldTable?: Table) {
`Column "${key}" has subtype "${column.subtype}" - this is not supported.` `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.`
)
}
}
} }
} }