Merge pull request #14193 from Budibase/support-spaces-as-last-char-in-column-sqs
Support space as last char in column name in SQS
This commit is contained in:
commit
4fa680df51
|
@ -42,27 +42,28 @@ const envLimit = environment.SQL_MAX_ROWS
|
||||||
: null
|
: null
|
||||||
const BASE_LIMIT = envLimit || 5000
|
const BASE_LIMIT = envLimit || 5000
|
||||||
|
|
||||||
function likeKey(client: string | string[], key: string): string {
|
// Takes a string like foo and returns a quoted string like [foo] for SQL Server
|
||||||
let start: string, end: string
|
// and "foo" for Postgres.
|
||||||
|
function quote(client: SqlClient, str: string): string {
|
||||||
switch (client) {
|
switch (client) {
|
||||||
case SqlClient.MY_SQL:
|
|
||||||
start = end = "`"
|
|
||||||
break
|
|
||||||
case SqlClient.SQL_LITE:
|
case SqlClient.SQL_LITE:
|
||||||
case SqlClient.ORACLE:
|
case SqlClient.ORACLE:
|
||||||
case SqlClient.POSTGRES:
|
case SqlClient.POSTGRES:
|
||||||
start = end = '"'
|
return `"${str}"`
|
||||||
break
|
|
||||||
case SqlClient.MS_SQL:
|
case SqlClient.MS_SQL:
|
||||||
start = "["
|
return `[${str}]`
|
||||||
end = "]"
|
case SqlClient.MY_SQL:
|
||||||
break
|
return `\`${str}\``
|
||||||
default:
|
|
||||||
throw new Error("Unknown client generating like key")
|
|
||||||
}
|
}
|
||||||
const parts = key.split(".")
|
}
|
||||||
key = parts.map(part => `${start}${part}${end}`).join(".")
|
|
||||||
|
// Takes a string like a.b.c and returns a quoted identifier like [a].[b].[c]
|
||||||
|
// for SQL Server and `a`.`b`.`c` for MySQL.
|
||||||
|
function quotedIdentifier(client: SqlClient, key: string): string {
|
||||||
return key
|
return key
|
||||||
|
.split(".")
|
||||||
|
.map(part => quote(client, part))
|
||||||
|
.join(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
function parse(input: any) {
|
function parse(input: any) {
|
||||||
|
@ -113,34 +114,81 @@ function generateSelectStatement(
|
||||||
knex: Knex
|
knex: Knex
|
||||||
): (string | Knex.Raw)[] | "*" {
|
): (string | Knex.Raw)[] | "*" {
|
||||||
const { resource, meta } = json
|
const { resource, meta } = json
|
||||||
|
const client = knex.client.config.client as SqlClient
|
||||||
|
|
||||||
if (!resource || !resource.fields || resource.fields.length === 0) {
|
if (!resource || !resource.fields || resource.fields.length === 0) {
|
||||||
return "*"
|
return "*"
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = meta?.table?.schema
|
const schema = meta.table.schema
|
||||||
return resource.fields.map(field => {
|
return resource.fields.map(field => {
|
||||||
const fieldNames = field.split(/\./g)
|
const parts = field.split(/\./g)
|
||||||
const tableName = fieldNames[0]
|
let table: string | undefined = undefined
|
||||||
const columnName = fieldNames[1]
|
let column: string | undefined = undefined
|
||||||
const columnSchema = schema?.[columnName]
|
|
||||||
if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) {
|
// Just a column name, e.g.: "column"
|
||||||
const externalType = schema[columnName].externalType
|
if (parts.length === 1) {
|
||||||
if (externalType?.includes("money")) {
|
column = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A table name and a column name, e.g.: "table.column"
|
||||||
|
if (parts.length === 2) {
|
||||||
|
table = parts[0]
|
||||||
|
column = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link doc, e.g.: "table.doc1.fieldName"
|
||||||
|
if (parts.length > 2) {
|
||||||
|
table = parts[0]
|
||||||
|
column = parts.slice(1).join(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!column) {
|
||||||
|
throw new Error(`Invalid field name: ${field}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnSchema = schema[column]
|
||||||
|
|
||||||
|
if (
|
||||||
|
client === SqlClient.POSTGRES &&
|
||||||
|
columnSchema?.externalType?.includes("money")
|
||||||
|
) {
|
||||||
return knex.raw(
|
return knex.raw(
|
||||||
`"${tableName}"."${columnName}"::money::numeric as "${field}"`
|
`${quotedIdentifier(
|
||||||
|
client,
|
||||||
|
[table, column].join(".")
|
||||||
|
)}::money::numeric as ${quote(client, field)}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
knex.client.config.client === SqlClient.MS_SQL &&
|
client === SqlClient.MS_SQL &&
|
||||||
columnSchema?.type === FieldType.DATETIME &&
|
columnSchema?.type === FieldType.DATETIME &&
|
||||||
columnSchema.timeOnly
|
columnSchema.timeOnly
|
||||||
) {
|
) {
|
||||||
// Time gets returned as timestamp from mssql, not matching the expected HH:mm format
|
// Time gets returned as timestamp from mssql, not matching the expected
|
||||||
|
// HH:mm format
|
||||||
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
|
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
|
||||||
}
|
}
|
||||||
return `${field} as ${field}`
|
|
||||||
|
// There's at least two edge cases being handled in the expression below.
|
||||||
|
// 1. The column name could start/end with a space, and in that case we
|
||||||
|
// want to preseve that space.
|
||||||
|
// 2. Almost all column names are specified in the form table.column, except
|
||||||
|
// in the case of relationships, where it's table.doc1.column. In that
|
||||||
|
// case, we want to split it into `table`.`doc1.column` for reasons that
|
||||||
|
// aren't actually clear to me, but `table`.`doc1` breaks things with the
|
||||||
|
// sample data tests.
|
||||||
|
if (table) {
|
||||||
|
return knex.raw(
|
||||||
|
`${quote(client, table)}.${quote(client, column)} as ${quote(
|
||||||
|
client,
|
||||||
|
field
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return knex.raw(`${quote(client, field)} as ${quote(client, field)}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,9 +221,9 @@ function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
class InternalBuilder {
|
class InternalBuilder {
|
||||||
private readonly client: string
|
private readonly client: SqlClient
|
||||||
|
|
||||||
constructor(client: string) {
|
constructor(client: SqlClient) {
|
||||||
this.client = client
|
this.client = client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -250,9 +298,10 @@ class InternalBuilder {
|
||||||
} else {
|
} else {
|
||||||
const rawFnc = `${fnc}Raw`
|
const rawFnc = `${fnc}Raw`
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
|
query = query[rawFnc](
|
||||||
`%${value.toLowerCase()}%`,
|
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
|
||||||
])
|
[`%${value.toLowerCase()}%`]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,7 +351,10 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
statement +=
|
statement +=
|
||||||
(statement ? andOr : "") +
|
(statement ? andOr : "") +
|
||||||
`COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?`
|
`COALESCE(LOWER(${quotedIdentifier(
|
||||||
|
this.client,
|
||||||
|
key
|
||||||
|
)}), '') LIKE ?`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statement === "") {
|
if (statement === "") {
|
||||||
|
@ -336,9 +388,10 @@ class InternalBuilder {
|
||||||
} else {
|
} else {
|
||||||
const rawFnc = `${fnc}Raw`
|
const rawFnc = `${fnc}Raw`
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
|
query = query[rawFnc](
|
||||||
`${value.toLowerCase()}%`,
|
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
|
||||||
])
|
[`${value.toLowerCase()}%`]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -376,12 +429,15 @@ class InternalBuilder {
|
||||||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
if (this.client === SqlClient.MS_SQL) {
|
if (this.client === SqlClient.MS_SQL) {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 1`,
|
`CASE WHEN ${quotedIdentifier(
|
||||||
|
this.client,
|
||||||
|
key
|
||||||
|
)} = ? THEN 1 ELSE 0 END = 1`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`COALESCE(${likeKey(this.client, key)} = ?, FALSE)`,
|
`COALESCE(${quotedIdentifier(this.client, key)} = ?, FALSE)`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -392,12 +448,15 @@ class InternalBuilder {
|
||||||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
if (this.client === SqlClient.MS_SQL) {
|
if (this.client === SqlClient.MS_SQL) {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 0`,
|
`CASE WHEN ${quotedIdentifier(
|
||||||
|
this.client,
|
||||||
|
key
|
||||||
|
)} = ? THEN 1 ELSE 0 END = 0`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`COALESCE(${likeKey(this.client, key)} != ?, TRUE)`,
|
`COALESCE(${quotedIdentifier(this.client, key)} != ?, TRUE)`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -769,7 +828,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
||||||
private readonly limit: number
|
private readonly limit: number
|
||||||
|
|
||||||
// pass through client to get flavour of SQL
|
// pass through client to get flavour of SQL
|
||||||
constructor(client: string, limit: number = BASE_LIMIT) {
|
constructor(client: SqlClient, limit: number = BASE_LIMIT) {
|
||||||
super(client)
|
super(client)
|
||||||
this.limit = limit
|
this.limit = limit
|
||||||
}
|
}
|
||||||
|
|
|
@ -195,14 +195,14 @@ function buildDeleteTable(knex: SchemaBuilder, table: Table): SchemaBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
class SqlTableQueryBuilder {
|
class SqlTableQueryBuilder {
|
||||||
private readonly sqlClient: string
|
private readonly sqlClient: SqlClient
|
||||||
|
|
||||||
// pass through client to get flavour of SQL
|
// pass through client to get flavour of SQL
|
||||||
constructor(client: string) {
|
constructor(client: SqlClient) {
|
||||||
this.sqlClient = client
|
this.sqlClient = client
|
||||||
}
|
}
|
||||||
|
|
||||||
getSqlClient(): string {
|
getSqlClient(): SqlClient {
|
||||||
return this.sqlClient
|
return this.sqlClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,7 +49,7 @@ describe.each([
|
||||||
const isSqs = name === "sqs"
|
const isSqs = name === "sqs"
|
||||||
const isLucene = name === "lucene"
|
const isLucene = name === "lucene"
|
||||||
const isInMemory = name === "in-memory"
|
const isInMemory = name === "in-memory"
|
||||||
const isInternal = isSqs || isLucene
|
const isInternal = isSqs || isLucene || isInMemory
|
||||||
const config = setup.getConfig()
|
const config = setup.getConfig()
|
||||||
|
|
||||||
let envCleanup: (() => void) | undefined
|
let envCleanup: (() => void) | undefined
|
||||||
|
@ -115,10 +115,7 @@ describe.each([
|
||||||
if (isInMemory) {
|
if (isInMemory) {
|
||||||
return dataFilters.search(_.cloneDeep(rows), this.query)
|
return dataFilters.search(_.cloneDeep(rows), this.query)
|
||||||
} else {
|
} else {
|
||||||
return config.api.row.search(table._id!, {
|
return config.api.row.search(this.query.tableId, this.query)
|
||||||
...this.query,
|
|
||||||
tableId: table._id!,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2182,8 +2179,7 @@ describe.each([
|
||||||
}).toContainExactly([{ name: "baz", productCat: undefined }])
|
}).toContainExactly([{ name: "baz", productCat: undefined }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
;(isSqs || isLucene) &&
|
||||||
isInternal &&
|
|
||||||
describe("relations to same table", () => {
|
describe("relations to same table", () => {
|
||||||
let relatedTable: Table, relatedRows: Row[]
|
let relatedTable: Table, relatedRows: Row[]
|
||||||
|
|
||||||
|
@ -2371,6 +2367,7 @@ describe.each([
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.api.application.addSampleData(config.appId!)
|
await config.api.application.addSampleData(config.appId!)
|
||||||
table = DEFAULT_EMPLOYEE_TABLE_SCHEMA
|
table = DEFAULT_EMPLOYEE_TABLE_SCHEMA
|
||||||
|
rows = await config.api.row.fetch(table._id!)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to search sample data", async () => {
|
it("should be able to search sample data", async () => {
|
||||||
|
@ -2455,4 +2452,76 @@ describe.each([
|
||||||
}).toContainExactly([{ [name]: "a" }])
|
}).toContainExactly([{ [name]: "a" }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// This is currently not supported in external datasources, it produces SQL
|
||||||
|
// errors at time of writing. We supported it (potentially by accident) in
|
||||||
|
// Lucene, though, so we need to make sure it's supported in SQS as well. We
|
||||||
|
// found real cases in production of column names ending in a space.
|
||||||
|
isInternal &&
|
||||||
|
describe("space at end of column name", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await createTable({
|
||||||
|
"name ": {
|
||||||
|
name: "name ",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createRows([{ ["name "]: "foo" }, { ["name "]: "bar" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that ends with a space", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
"name ": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ ["name "]: "foo" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that ends with a space using numeric notation", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
"1:name ": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ ["name "]: "foo" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// This was never actually supported in Lucene but SQS does support it, so may
|
||||||
|
// as well have a test for it.
|
||||||
|
;(isSqs || isInMemory) &&
|
||||||
|
describe("space at start of column name", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await createTable({
|
||||||
|
" name": {
|
||||||
|
name: " name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createRows([{ [" name"]: "foo" }, { [" name"]: "bar" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that starts with a space", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
" name": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ [" name"]: "foo" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that starts with a space using numeric notation", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
"1: name": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ [" name"]: "foo" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -272,9 +272,9 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
let readableMessage = getReadableErrorMessage(SourceName.MYSQL, err.errno)
|
let readableMessage = getReadableErrorMessage(SourceName.MYSQL, err.errno)
|
||||||
if (readableMessage) {
|
if (readableMessage) {
|
||||||
throw new Error(readableMessage)
|
throw new Error(readableMessage, { cause: err })
|
||||||
} else {
|
} else {
|
||||||
throw new Error(err.message as string)
|
throw err
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (opts?.connect && this.client) {
|
if (opts?.connect && this.client) {
|
||||||
|
|
|
@ -210,7 +210,6 @@ async function runSqlQuery(
|
||||||
let bindings = query.bindings
|
let bindings = query.bindings
|
||||||
|
|
||||||
// quick hack for docIds
|
// quick hack for docIds
|
||||||
|
|
||||||
const fixJunctionDocs = (field: string) =>
|
const fixJunctionDocs = (field: string) =>
|
||||||
["doc1", "doc2"].forEach(doc => {
|
["doc1", "doc2"].forEach(doc => {
|
||||||
sql = sql.replaceAll(`\`${doc}\`.\`${field}\``, `\`${doc}.${field}\``)
|
sql = sql.replaceAll(`\`${doc}\`.\`${field}\``, `\`${doc}.${field}\``)
|
||||||
|
|
Loading…
Reference in New Issue