diff --git a/packages/backend-core/src/middleware/errorHandling.ts b/packages/backend-core/src/middleware/errorHandling.ts index 6ceda9cd3a..5a5a25b461 100644 --- a/packages/backend-core/src/middleware/errorHandling.ts +++ b/packages/backend-core/src/middleware/errorHandling.ts @@ -32,8 +32,12 @@ export async function errorHandling(ctx: any, next: any) { } if (environment.isTest() && ctx.headers["x-budibase-include-stacktrace"]) { + let rootErr = err + while (rootErr.cause) { + rootErr = rootErr.cause + } // @ts-ignore - error.stack = err.stack + error.stack = rootErr.stack } ctx.body = error diff --git a/packages/backend-core/src/sql/sql.ts b/packages/backend-core/src/sql/sql.ts index 5f462ee144..9b0c49d9f8 100644 --- a/packages/backend-core/src/sql/sql.ts +++ b/packages/backend-core/src/sql/sql.ts @@ -816,14 +816,29 @@ class InternalBuilder { filters.oneOf, ArrayOperator.ONE_OF, (q, key: string, array) => { + const schema = this.getFieldSchema(key) + const values = Array.isArray(array) ? array : [array] if (shouldOr) { q = q.or } if (this.client === SqlClient.ORACLE) { // @ts-ignore key = this.convertClobs(key) + } else if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + for (const value of values) { + if (value != null) { + q = q.or.whereLike(key, `${value.toISOString().slice(0, 10)}%`) + } else { + q = q.or.whereNull(key) + } + } + return q } - return q.whereIn(key, Array.isArray(array) ? array : [array]) + return q.whereIn(key, values) }, (q, key: string[], array) => { if (shouldOr) { @@ -882,6 +897,19 @@ class InternalBuilder { let high = value.high let low = value.low + if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + if (high != null) { + high = `${high.toISOString().slice(0, 10)}T23:59:59.999Z` + } + if (low != null) { + low = low.toISOString().slice(0, 10) + } + } + if (this.client === SqlClient.ORACLE) { rawKey = this.convertClobs(key) } else if ( @@ -914,6 +942,7 @@ class InternalBuilder { } if (filters.equal) { iterate(filters.equal, BasicOperator.EQUAL, (q, key, value) => { + const schema = this.getFieldSchema(key) if (shouldOr) { q = q.or } @@ -928,6 +957,16 @@ class InternalBuilder { // @ts-expect-error knex types are wrong, raw is fine here subq.whereNotNull(identifier).andWhere(identifier, value) ) + } else if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + if (value != null) { + return q.whereLike(key, `${value.toISOString().slice(0, 10)}%`) + } else { + return q.whereNull(key) + } } else { return q.whereRaw(`COALESCE(?? = ?, FALSE)`, [ this.rawQuotedIdentifier(key), @@ -938,6 +977,7 @@ class InternalBuilder { } if (filters.notEqual) { iterate(filters.notEqual, BasicOperator.NOT_EQUAL, (q, key, value) => { + const schema = this.getFieldSchema(key) if (shouldOr) { q = q.or } @@ -959,6 +999,18 @@ class InternalBuilder { // @ts-expect-error knex types are wrong, raw is fine here .or.whereNull(identifier) ) + } else if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + if (value != null) { + return q.not + .whereLike(key, `${value.toISOString().slice(0, 10)}%`) + .or.whereNull(key) + } else { + return q.not.whereNull(key) + } } else { return q.whereRaw(`COALESCE(?? != ?, TRUE)`, [ this.rawQuotedIdentifier(key), diff --git a/packages/backend-core/src/sql/utils.ts b/packages/backend-core/src/sql/utils.ts index 16b352995b..b07854b2a0 100644 --- a/packages/backend-core/src/sql/utils.ts +++ b/packages/backend-core/src/sql/utils.ts @@ -14,7 +14,7 @@ import environment from "../environment" const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` const ROW_ID_REGEX = /^\[.*]$/g 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) { @@ -149,15 +149,7 @@ export function isInvalidISODateString(str: string) { } export function isValidISODateString(str: string) { - const trimmedValue = str.trim() - if (!ISO_DATE_REGEX.test(trimmedValue)) { - return false - } - let d = new Date(trimmedValue) - if (isNaN(d.getTime())) { - return false - } - return d.toISOString() === trimmedValue + return ISO_DATE_REGEX.test(str.trim()) } export function isValidFilter(value: any) { diff --git a/packages/server/src/api/routes/tests/search.spec.ts b/packages/server/src/api/routes/tests/search.spec.ts index e94e567b43..dca1bf9394 100644 --- a/packages/server/src/api/routes/tests/search.spec.ts +++ b/packages/server/src/api/routes/tests/search.spec.ts @@ -1683,6 +1683,112 @@ if (descriptions.length) { }) }) + describe.only("datetime - date only", () => { + const JAN_1ST = "2020-01-01" + const JAN_10TH = "2020-01-10" + const JAN_30TH = "2020-01-30" + const UNEXISTING_DATE = "2020-01-03" + const NULL_DATE__ID = `null_date__id` + + beforeAll(async () => { + tableOrViewId = await createTableOrView({ + dateid: { name: "dateid", type: FieldType.STRING }, + date: { + name: "date", + type: FieldType.DATETIME, + dateOnly: true, + }, + }) + + await createRows([ + { dateid: NULL_DATE__ID, date: null }, + { date: JAN_1ST }, + { date: `${JAN_10TH}T00:00:00.000Z` }, + ]) + }) + + describe("equal", () => { + it("successfully finds a row", async () => { + await expectQuery({ + equal: { date: JAN_1ST }, + }).toContainExactly([{ date: JAN_1ST }]) + }) + + it("successfully finds an ISO8601 row", async () => { + await expectQuery({ + equal: { date: JAN_10TH }, + }).toContainExactly([{ date: JAN_10TH }]) + }) + + it("finds a row with ISO8601 timestamp", async () => { + await expectQuery({ + equal: { date: `${JAN_1ST}T00:00:00.000Z` }, + }).toContainExactly([{ date: JAN_1ST }]) + }) + + it("fails to find nonexistent row", async () => { + await expectQuery({ + equal: { date: UNEXISTING_DATE }, + }).toFindNothing() + }) + }) + + describe("notEqual", () => { + it("successfully finds a row", async () => { + await expectQuery({ + notEqual: { date: JAN_1ST }, + }).toContainExactly([ + { date: JAN_10TH }, + { dateid: NULL_DATE__ID }, + ]) + }) + + it("fails to find nonexistent row", async () => { + await expectQuery({ + notEqual: { date: JAN_30TH }, + }).toContainExactly([ + { date: JAN_1ST }, + { date: JAN_10TH }, + { dateid: NULL_DATE__ID }, + ]) + }) + }) + + describe("oneOf", () => { + it("successfully finds a row", async () => { + await expectQuery({ + oneOf: { date: [JAN_1ST] }, + }).toContainExactly([{ date: JAN_1ST }]) + }) + + it("fails to find nonexistent row", async () => { + await expectQuery({ + oneOf: { date: [UNEXISTING_DATE] }, + }).toFindNothing() + }) + }) + + describe("range", () => { + it("successfully finds a row", async () => { + await expectQuery({ + range: { date: { low: JAN_1ST, high: JAN_1ST } }, + }).toContainExactly([{ date: JAN_1ST }]) + }) + + it("successfully finds multiple rows", async () => { + await expectQuery({ + range: { date: { low: JAN_1ST, high: JAN_10TH } }, + }).toContainExactly([{ date: JAN_1ST }, { date: JAN_10TH }]) + }) + + it("successfully finds no rows", async () => { + await expectQuery({ + range: { date: { low: JAN_30TH, high: JAN_30TH } }, + }).toFindNothing() + }) + }) + }) + isInternal && !isInMemory && describe("AI Column", () => { diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index 14b524fd95..8595a3483e 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -411,6 +411,15 @@ export async function coreOutputProcessing( row[property] = `${hours}:${minutes}:${seconds}` } } + } else if (column.type === FieldType.DATETIME && column.dateOnly) { + for (const row of rows) { + if (typeof row[property] === "string") { + row[property] = new Date(row[property]) + } + if (row[property] instanceof Date) { + row[property] = row[property].toISOString().slice(0, 10) + } + } } else if (column.type === FieldType.LINK) { for (let row of rows) { // if relationship is empty - remove the array, this has been part of the API for some time diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index b711d4cb61..afe99d9565 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -699,7 +699,27 @@ export function runQuery>( return docValue._id === testValue } - return docValue === testValue + if (docValue === testValue) { + return true + } + + if (docValue == null && testValue != null) { + return false + } + + if (docValue != null && testValue == null) { + return false + } + + const leftDate = dayjs(docValue) + if (leftDate.isValid()) { + const rightDate = dayjs(testValue) + if (rightDate.isValid()) { + return leftDate.isSame(rightDate) + } + } + + return false } const not =