Checkpoint EOD: fixed a bunch more raw cases, some test failures to fix tomorrow.
This commit is contained in:
parent
4545493cd5
commit
56a68db1d4
|
@ -13,6 +13,7 @@ import SqlTableQueryBuilder from "./sqlTable"
|
||||||
import {
|
import {
|
||||||
Aggregation,
|
Aggregation,
|
||||||
AnySearchFilter,
|
AnySearchFilter,
|
||||||
|
ArrayFilter,
|
||||||
ArrayOperator,
|
ArrayOperator,
|
||||||
BasicOperator,
|
BasicOperator,
|
||||||
BBReferenceFieldMetadata,
|
BBReferenceFieldMetadata,
|
||||||
|
@ -152,30 +153,24 @@ class InternalBuilder {
|
||||||
return this.query.meta.table
|
return this.query.meta.table
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get knexClient(): Knex.Client {
|
||||||
|
return this.knex.client as Knex.Client
|
||||||
|
}
|
||||||
|
|
||||||
getFieldSchema(key: string): FieldSchema | undefined {
|
getFieldSchema(key: string): FieldSchema | undefined {
|
||||||
const { column } = this.splitter.run(key)
|
const { column } = this.splitter.run(key)
|
||||||
return this.table.schema[column]
|
return this.table.schema[column]
|
||||||
}
|
}
|
||||||
|
|
||||||
private quoteChars(): [string, string] {
|
private quoteChars(): [string, string] {
|
||||||
switch (this.client) {
|
const wrapped = this.knexClient.wrapIdentifier("foo", {})
|
||||||
case SqlClient.ORACLE:
|
return [wrapped[0], wrapped[wrapped.length - 1]]
|
||||||
case SqlClient.POSTGRES:
|
|
||||||
return ['"', '"']
|
|
||||||
case SqlClient.MS_SQL:
|
|
||||||
return ["[", "]"]
|
|
||||||
case SqlClient.MARIADB:
|
|
||||||
case SqlClient.MY_SQL:
|
|
||||||
case SqlClient.SQL_LITE:
|
|
||||||
return ["`", "`"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Takes a string like foo and returns a quoted string like [foo] for SQL Server
|
// Takes a string like foo and returns a quoted string like [foo] for SQL
|
||||||
// and "foo" for Postgres.
|
// Server and "foo" for Postgres.
|
||||||
private quote(str: string): string {
|
private quote(str: string): string {
|
||||||
const [start, end] = this.quoteChars()
|
return this.knexClient.wrapIdentifier(str, {})
|
||||||
return `${start}${str}${end}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isQuoted(key: string): boolean {
|
private isQuoted(key: string): boolean {
|
||||||
|
@ -193,6 +188,21 @@ class InternalBuilder {
|
||||||
return key.map(part => this.quote(part)).join(".")
|
return key.map(part => this.quote(part)).join(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unfortuantely we cannot rely on knex's identifier escaping because it trims
|
||||||
|
// the identifier string before escaping it, which breaks cases for us where
|
||||||
|
// columns that start or end with a space aren't referenced correctly anymore.
|
||||||
|
//
|
||||||
|
// So whenever you're using an identifier binding in knex, e.g. knex.raw("??
|
||||||
|
// as ?", ["foo", "bar"]), you need to make sure you call this:
|
||||||
|
//
|
||||||
|
// knex.raw("?? as ?", [this.quotedIdentifier("foo"), "bar"])
|
||||||
|
//
|
||||||
|
// Issue we filed against knex about this:
|
||||||
|
// https://github.com/knex/knex/issues/6143
|
||||||
|
private rawQuotedIdentifier(key: string): Knex.Raw {
|
||||||
|
return this.knex.raw(this.quotedIdentifier(key))
|
||||||
|
}
|
||||||
|
|
||||||
// Turns an identifier like a.b.c or `a`.`b`.`c` into ["a", "b", "c"]
|
// Turns an identifier like a.b.c or `a`.`b`.`c` into ["a", "b", "c"]
|
||||||
private splitIdentifier(key: string): string[] {
|
private splitIdentifier(key: string): string[] {
|
||||||
const [start, end] = this.quoteChars()
|
const [start, end] = this.quoteChars()
|
||||||
|
@ -261,7 +271,7 @@ class InternalBuilder {
|
||||||
// TODO: figure out how to express this safely without string
|
// TODO: figure out how to express this safely without string
|
||||||
// interpolation.
|
// interpolation.
|
||||||
return this.knex.raw(`??::money::numeric as "${field}"`, [
|
return this.knex.raw(`??::money::numeric as "${field}"`, [
|
||||||
[table, column].join("."),
|
this.rawQuotedIdentifier([table, column].join(".")),
|
||||||
field,
|
field,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
@ -273,14 +283,14 @@ class InternalBuilder {
|
||||||
// TODO: figure out how to express this safely without string
|
// TODO: figure out how to express this safely without string
|
||||||
// interpolation.
|
// interpolation.
|
||||||
return this.knex.raw(`CONVERT(varchar, ??, 108) as "${field}"`, [
|
return this.knex.raw(`CONVERT(varchar, ??, 108) as "${field}"`, [
|
||||||
field,
|
this.rawQuotedIdentifier(field),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (table) {
|
if (table) {
|
||||||
return this.knex.raw("??", [`${table}.${column}`])
|
return this.rawQuotedIdentifier(`${table}.${column}`)
|
||||||
} else {
|
} else {
|
||||||
return this.knex.raw("??", [field])
|
return this.rawQuotedIdentifier(field)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -300,7 +310,7 @@ class InternalBuilder {
|
||||||
const parts = this.splitIdentifier(field)
|
const parts = this.splitIdentifier(field)
|
||||||
const col = parts.pop()!
|
const col = parts.pop()!
|
||||||
const schema = this.table.schema[col]
|
const schema = this.table.schema[col]
|
||||||
let identifier = this.knex.raw("??", [field])
|
let identifier = this.rawQuotedIdentifier(field)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
schema.type === FieldType.STRING ||
|
schema.type === FieldType.STRING ||
|
||||||
|
@ -311,7 +321,10 @@ class InternalBuilder {
|
||||||
schema.type === FieldType.BARCODEQR
|
schema.type === FieldType.BARCODEQR
|
||||||
) {
|
) {
|
||||||
if (opts?.forSelect) {
|
if (opts?.forSelect) {
|
||||||
identifier = this.knex.raw("to_char(??) as ??", [identifier, col])
|
identifier = this.knex.raw("to_char(??) as ??", [
|
||||||
|
identifier,
|
||||||
|
this.rawQuotedIdentifier(col),
|
||||||
|
])
|
||||||
} else {
|
} else {
|
||||||
identifier = this.knex.raw("to_char(??)", [identifier])
|
identifier = this.knex.raw("to_char(??)", [identifier])
|
||||||
}
|
}
|
||||||
|
@ -437,7 +450,6 @@ class InternalBuilder {
|
||||||
filterKey: string,
|
filterKey: string,
|
||||||
whereCb: (filterKey: string, query: Knex.QueryBuilder) => Knex.QueryBuilder
|
whereCb: (filterKey: string, query: Knex.QueryBuilder) => Knex.QueryBuilder
|
||||||
): Knex.QueryBuilder {
|
): Knex.QueryBuilder {
|
||||||
const mainKnex = this.knex
|
|
||||||
const { relationships, endpoint, tableAliases: aliases } = this.query
|
const { relationships, endpoint, tableAliases: aliases } = this.query
|
||||||
const tableName = endpoint.entityId
|
const tableName = endpoint.entityId
|
||||||
const fromAlias = aliases?.[tableName] || tableName
|
const fromAlias = aliases?.[tableName] || tableName
|
||||||
|
@ -459,8 +471,8 @@ class InternalBuilder {
|
||||||
relationship.to &&
|
relationship.to &&
|
||||||
relationship.tableName
|
relationship.tableName
|
||||||
) {
|
) {
|
||||||
const joinTable = mainKnex
|
const joinTable = this.knex
|
||||||
.select(mainKnex.raw(1))
|
.select(this.knex.raw(1))
|
||||||
.from({ [toAlias]: relatedTableName })
|
.from({ [toAlias]: relatedTableName })
|
||||||
let subQuery = joinTable.clone()
|
let subQuery = joinTable.clone()
|
||||||
const manyToMany = validateManyToMany(relationship)
|
const manyToMany = validateManyToMany(relationship)
|
||||||
|
@ -495,7 +507,7 @@ class InternalBuilder {
|
||||||
.where(
|
.where(
|
||||||
`${throughAlias}.${manyToMany.from}`,
|
`${throughAlias}.${manyToMany.from}`,
|
||||||
"=",
|
"=",
|
||||||
mainKnex.raw(`??`, [`${fromAlias}.${manyToMany.fromPrimary}`])
|
this.rawQuotedIdentifier(`${fromAlias}.${manyToMany.fromPrimary}`)
|
||||||
)
|
)
|
||||||
// in SQS the same junction table is used for different many-to-many relationships between the
|
// in SQS the same junction table is used for different many-to-many relationships between the
|
||||||
// two same tables, this is needed to avoid rows ending up in all columns
|
// two same tables, this is needed to avoid rows ending up in all columns
|
||||||
|
@ -524,7 +536,7 @@ class InternalBuilder {
|
||||||
subQuery = subQuery.where(
|
subQuery = subQuery.where(
|
||||||
toKey,
|
toKey,
|
||||||
"=",
|
"=",
|
||||||
mainKnex.raw("??", [foreignKey])
|
this.rawQuotedIdentifier(foreignKey)
|
||||||
)
|
)
|
||||||
|
|
||||||
query = query.where(q => {
|
query = query.where(q => {
|
||||||
|
@ -641,15 +653,30 @@ class InternalBuilder {
|
||||||
this.client === SqlClient.ORACLE ||
|
this.client === SqlClient.ORACLE ||
|
||||||
this.client === SqlClient.SQL_LITE
|
this.client === SqlClient.SQL_LITE
|
||||||
) {
|
) {
|
||||||
return q.whereRaw(`LOWER(??) LIKE ?`, [key, `%${value.toLowerCase()}%`])
|
return q.whereRaw(`LOWER(??) LIKE ?`, [
|
||||||
|
this.rawQuotedIdentifier(key),
|
||||||
|
`%${value.toLowerCase()}%`,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
return q.whereILike(key, this.knex.raw("?", [`%${value}%`]))
|
return q.whereILike(
|
||||||
|
// @ts-expect-error knex types are wrong, raw is fine here
|
||||||
|
this.rawQuotedIdentifier(key),
|
||||||
|
this.knex.raw("?", [`%${value}%`])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const contains = (mode: AnySearchFilter, any: boolean = false) => {
|
const contains = (mode: ArrayFilter, any = false) => {
|
||||||
const rawFnc = allOr ? "orWhereRaw" : "whereRaw"
|
function addModifiers<T extends {}, Q>(q: Knex.QueryBuilder<T, Q>) {
|
||||||
const not = mode === filters?.notContains ? "NOT " : ""
|
if (allOr || mode === filters?.containsAny) {
|
||||||
function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
|
q = q.or
|
||||||
|
}
|
||||||
|
if (mode === filters?.notContains) {
|
||||||
|
q = q.not
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyArray(value: any[], quoteStyle = '"'): string {
|
||||||
for (let i in value) {
|
for (let i in value) {
|
||||||
if (typeof value[i] === "string") {
|
if (typeof value[i] === "string") {
|
||||||
value[i] = `${quoteStyle}${value[i]}${quoteStyle}`
|
value[i] = `${quoteStyle}${value[i]}${quoteStyle}`
|
||||||
|
@ -657,60 +684,64 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
return `[${value.join(",")}]`
|
return `[${value.join(",")}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.client === SqlClient.POSTGRES) {
|
if (this.client === SqlClient.POSTGRES) {
|
||||||
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
||||||
const wrap = any ? "" : "'"
|
const wrap = any ? "" : "'"
|
||||||
const op = any ? "\\?| array" : "@>"
|
const op = any ? "\\?| array" : "@>"
|
||||||
const fieldNames = key.split(/\./g)
|
|
||||||
const table = fieldNames[0]
|
const stringifiedArray = stringifyArray(value, any ? "'" : '"')
|
||||||
const col = fieldNames[1]
|
return addModifiers(q).whereRaw(
|
||||||
return q[rawFnc](
|
`COALESCE(??::jsonb ${op} ${wrap}${stringifiedArray}${wrap}, FALSE)`,
|
||||||
`${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray(
|
[this.rawQuotedIdentifier(key)]
|
||||||
value,
|
|
||||||
any ? "'" : '"'
|
|
||||||
)}${wrap}, FALSE)`
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.client === SqlClient.MY_SQL ||
|
this.client === SqlClient.MY_SQL ||
|
||||||
this.client === SqlClient.MARIADB
|
this.client === SqlClient.MARIADB
|
||||||
) {
|
) {
|
||||||
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
|
|
||||||
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
||||||
return q[rawFnc](
|
return addModifiers(q).whereRaw(`COALESCE(?(??, ?), FALSE)`, [
|
||||||
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
|
this.knex.raw(any ? "JSON_OVERLAPS" : "JSON_CONTAINS"),
|
||||||
value
|
this.rawQuotedIdentifier(key),
|
||||||
)}'), FALSE)`
|
stringifyArray(value),
|
||||||
)
|
])
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const andOr = mode === filters?.containsAny ? " OR " : " AND "
|
|
||||||
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
|
||||||
let statement = ""
|
if (value.length === 0) {
|
||||||
const identifier = this.quotedIdentifier(key)
|
|
||||||
for (let i in value) {
|
|
||||||
if (typeof value[i] === "string") {
|
|
||||||
value[i] = `%"${value[i].toLowerCase()}"%`
|
|
||||||
} else {
|
|
||||||
value[i] = `%${value[i]}%`
|
|
||||||
}
|
|
||||||
statement += `${
|
|
||||||
statement ? andOr : ""
|
|
||||||
}COALESCE(LOWER(${identifier}), '') LIKE ?`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statement === "") {
|
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
if (not) {
|
q = addModifiers(q).where(subQuery => {
|
||||||
return q[rawFnc](
|
subQuery.where(subSubQuery => {
|
||||||
`(NOT (${statement}) OR ${identifier} IS NULL)`,
|
for (const elem of value) {
|
||||||
value
|
if (mode === filters?.containsAny) {
|
||||||
)
|
subSubQuery = subSubQuery.or
|
||||||
} else {
|
} else {
|
||||||
return q[rawFnc](statement, value)
|
subSubQuery = subSubQuery.and
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower =
|
||||||
|
typeof elem === "string" ? `"${elem.toLowerCase()}"` : elem
|
||||||
|
|
||||||
|
subSubQuery = subSubQuery.whereLike(
|
||||||
|
// @ts-expect-error knex types are wrong, raw is fine here
|
||||||
|
this.knex.raw(`COALESCE(LOWER(??), '')`, [
|
||||||
|
this.rawQuotedIdentifier(key),
|
||||||
|
]),
|
||||||
|
`%${lower}%`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mode === filters?.notContains) {
|
||||||
|
// @ts-expect-error knex types are wrong, raw is fine here
|
||||||
|
q.or.whereNull(this.rawQuotedIdentifier(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return q
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -764,7 +795,10 @@ class InternalBuilder {
|
||||||
return q[fnc](key, "ilike", `${value}%`)
|
return q[fnc](key, "ilike", `${value}%`)
|
||||||
} else {
|
} else {
|
||||||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
return q[fnc](`LOWER(??) LIKE ?`, [key, `${value.toLowerCase()}%`])
|
return q[fnc](`LOWER(??) LIKE ?`, [
|
||||||
|
this.rawQuotedIdentifier(key),
|
||||||
|
`${value.toLowerCase()}%`,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -801,7 +835,9 @@ class InternalBuilder {
|
||||||
this.client === SqlClient.SQL_LITE &&
|
this.client === SqlClient.SQL_LITE &&
|
||||||
schema?.type === FieldType.BIGINT
|
schema?.type === FieldType.BIGINT
|
||||||
) {
|
) {
|
||||||
rawKey = this.knex.raw("CAST(?? AS INTEGER)", [key])
|
rawKey = this.knex.raw("CAST(?? AS INTEGER)", [
|
||||||
|
this.rawQuotedIdentifier(key),
|
||||||
|
])
|
||||||
high = this.knex.raw("CAST(? AS INTEGER)", [value.high])
|
high = this.knex.raw("CAST(? AS INTEGER)", [value.high])
|
||||||
low = this.knex.raw("CAST(? AS INTEGER)", [value.low])
|
low = this.knex.raw("CAST(? AS INTEGER)", [value.low])
|
||||||
}
|
}
|
||||||
|
@ -1082,7 +1118,9 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
const separator = this.client === SqlClient.ORACLE ? " VALUE " : ","
|
const separator = this.client === SqlClient.ORACLE ? " VALUE " : ","
|
||||||
return this.knex.raw(`?${separator}??`, [unaliased, tableField]).toString()
|
return this.knex
|
||||||
|
.raw(`?${separator}??`, [unaliased, this.rawQuotedIdentifier(tableField)])
|
||||||
|
.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
maxFunctionParameters() {
|
maxFunctionParameters() {
|
||||||
|
@ -1178,7 +1216,7 @@ class InternalBuilder {
|
||||||
subQuery = subQuery.where(
|
subQuery = subQuery.where(
|
||||||
correlatedTo,
|
correlatedTo,
|
||||||
"=",
|
"=",
|
||||||
knex.raw("??", [correlatedFrom])
|
this.rawQuotedIdentifier(correlatedFrom)
|
||||||
)
|
)
|
||||||
|
|
||||||
const standardWrap = (select: Knex.Raw): Knex.QueryBuilder => {
|
const standardWrap = (select: Knex.Raw): Knex.QueryBuilder => {
|
||||||
|
@ -1228,7 +1266,7 @@ class InternalBuilder {
|
||||||
|
|
||||||
wrapperQuery = knex.raw(
|
wrapperQuery = knex.raw(
|
||||||
`(SELECT ?? = (${comparatorQuery} FOR JSON PATH))`,
|
`(SELECT ?? = (${comparatorQuery} FOR JSON PATH))`,
|
||||||
[toAlias]
|
[this.rawQuotedIdentifier(toAlias)]
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -691,7 +691,7 @@ describe.each([
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not match the session user id in a multi user field", async () => {
|
it.only("should not match the session user id in a multi user field", async () => {
|
||||||
await expectQuery({
|
await expectQuery({
|
||||||
notContains: { multi_user: ["{{ [user]._id }}"] },
|
notContains: { multi_user: ["{{ [user]._id }}"] },
|
||||||
notEmpty: { multi_user: true },
|
notEmpty: { multi_user: true },
|
||||||
|
@ -793,7 +793,7 @@ describe.each([
|
||||||
})
|
})
|
||||||
|
|
||||||
const stringTypes = [FieldType.STRING, FieldType.LONGFORM] as const
|
const stringTypes = [FieldType.STRING, FieldType.LONGFORM] as const
|
||||||
describe.only.each(stringTypes)("%s", type => {
|
describe.each(stringTypes)("%s", type => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tableOrViewId = await createTableOrView({
|
tableOrViewId = await createTableOrView({
|
||||||
name: { name: "name", type },
|
name: { name: "name", type },
|
||||||
|
|
|
@ -49,7 +49,7 @@ type BasicFilter<T = any> = Record<string, T> & {
|
||||||
[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR]?: never
|
[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR]?: never
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArrayFilter = Record<string, any[]> & {
|
export type ArrayFilter = Record<string, any[]> & {
|
||||||
[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR]?: {
|
[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR]?: {
|
||||||
id: string[]
|
id: string[]
|
||||||
values: string[]
|
values: string[]
|
||||||
|
|
Loading…
Reference in New Issue