Merge branch 'master' into feature/audit-log-sqs
This commit is contained in:
commit
a3d2871330
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.27.2",
|
"version": "2.27.3",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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", () => {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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> = {
|
||||||
|
|
|
@ -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"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue