Fix range queries.

This commit is contained in:
Sam Rose 2024-07-30 11:54:46 +01:00
parent 0599257935
commit bc7501f72b
No known key found for this signature in database
3 changed files with 96 additions and 143 deletions

View File

@ -8,7 +8,6 @@ import {
sqlLog, sqlLog,
isInvalidISODateString, isInvalidISODateString,
} from "./utils" } from "./utils"
import { SqlStatements } from "./sqlStatements"
import SqlTableQueryBuilder from "./sqlTable" import SqlTableQueryBuilder from "./sqlTable"
import { import {
AnySearchFilter, AnySearchFilter,
@ -77,10 +76,12 @@ class InternalBuilder {
private readonly client: SqlClient private readonly client: SqlClient
private readonly query: QueryJson private readonly query: QueryJson
private readonly splitter: dataFilters.ColumnSplitter private readonly splitter: dataFilters.ColumnSplitter
private readonly knex: Knex
constructor(client: SqlClient, query: QueryJson) { constructor(client: SqlClient, knex: Knex, query: QueryJson) {
this.client = client this.client = client
this.query = query this.query = query
this.knex = knex
this.splitter = new dataFilters.ColumnSplitter([this.table], { this.splitter = new dataFilters.ColumnSplitter([this.table], {
aliases: this.query.tableAliases, aliases: this.query.tableAliases,
@ -92,6 +93,11 @@ class InternalBuilder {
return this.query.meta.table return this.query.meta.table
} }
getFieldSchema(key: string): FieldSchema | undefined {
const { column } = this.splitter.run(key)
return this.table.schema[column]
}
// 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 Server
// and "foo" for Postgres. // and "foo" for Postgres.
private quote(str: string): string { private quote(str: string): string {
@ -116,9 +122,8 @@ class InternalBuilder {
.join(".") .join(".")
} }
private generateSelectStatement(knex: Knex): (string | Knex.Raw)[] | "*" { private generateSelectStatement(): (string | Knex.Raw)[] | "*" {
const { resource, meta } = this.query const { resource, meta } = this.query
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 "*"
@ -154,10 +159,10 @@ class InternalBuilder {
const columnSchema = schema[column] const columnSchema = schema[column]
if ( if (
client === SqlClient.POSTGRES && this.client === SqlClient.POSTGRES &&
columnSchema?.externalType?.includes("money") columnSchema?.externalType?.includes("money")
) { ) {
return knex.raw( return this.knex.raw(
`${this.quotedIdentifier( `${this.quotedIdentifier(
[table, column].join(".") [table, column].join(".")
)}::money::numeric as ${this.quote(field)}` )}::money::numeric as ${this.quote(field)}`
@ -165,13 +170,13 @@ class InternalBuilder {
} }
if ( if (
client === SqlClient.MS_SQL && this.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 // Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format // HH:mm format
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`) return this.knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
} }
// There's at least two edge cases being handled in the expression below. // There's at least two edge cases being handled in the expression below.
@ -183,11 +188,11 @@ class InternalBuilder {
// aren't actually clear to me, but `table`.`doc1` breaks things with the // aren't actually clear to me, but `table`.`doc1` breaks things with the
// sample data tests. // sample data tests.
if (table) { if (table) {
return knex.raw( return this.knex.raw(
`${this.quote(table)}.${this.quote(column)} as ${this.quote(field)}` `${this.quote(table)}.${this.quote(column)} as ${this.quote(field)}`
) )
} else { } else {
return knex.raw(`${this.quote(field)} as ${this.quote(field)}`) return this.knex.raw(`${this.quote(field)} as ${this.quote(field)}`)
} }
}) })
} }
@ -333,10 +338,6 @@ class InternalBuilder {
const aliases = this.query.tableAliases const aliases = this.query.tableAliases
// if all or specified in filters, then everything is an or // if all or specified in filters, then everything is an or
const allOr = filters.allOr const allOr = filters.allOr
const sqlStatements = new SqlStatements(this.client, this.table, {
allOr,
columnPrefix: this.query.meta.columnPrefix,
})
const tableName = const tableName =
this.client === SqlClient.SQL_LITE ? this.table._id! : this.table.name this.client === SqlClient.SQL_LITE ? this.table._id! : this.table.name
@ -506,12 +507,53 @@ class InternalBuilder {
} }
const lowValid = isValidFilter(value.low), const lowValid = isValidFilter(value.low),
highValid = isValidFilter(value.high) highValid = isValidFilter(value.high)
const schema = this.getFieldSchema(key)
if (this.client === SqlClient.ORACLE) {
// @ts-ignore
key = this.knex.raw(this.convertClobs(key))
}
if (lowValid && highValid) { if (lowValid && highValid) {
query = sqlStatements.between(query, key, value.low, value.high) if (
schema?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(
`CAST(${key} AS INTEGER) BETWEEN CAST(? AS INTEGER) AND CAST(? AS INTEGER)`,
[value.low, value.high]
)
} else {
const fnc = allOr ? "orWhereBetween" : "whereBetween"
query = query[fnc](key, [value.low, value.high])
}
} else if (lowValid) { } else if (lowValid) {
query = sqlStatements.lte(query, key, value.low) if (
schema?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(
`CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`,
[value.low]
)
} else {
const fnc = allOr ? "orWhere" : "where"
query = query[fnc](key, ">=", value.low)
}
} else if (highValid) { } else if (highValid) {
query = sqlStatements.gte(query, key, value.high) if (
schema?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(
`CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`,
[value.high]
)
} else {
const fnc = allOr ? "orWhere" : "where"
query = query[fnc](key, "<=", value.high)
}
} }
}) })
} }
@ -621,17 +663,19 @@ class InternalBuilder {
for (let [key, value] of Object.entries(sort)) { for (let [key, value] of Object.entries(sort)) {
const direction = const direction =
value.direction === SortOrder.ASCENDING ? "asc" : "desc" value.direction === SortOrder.ASCENDING ? "asc" : "desc"
let nulls const nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
if (
this.client === SqlClient.POSTGRES ||
this.client === SqlClient.ORACLE
) {
// All other clients already sort this as expected by default, and
// adding this to the rest of the clients is causing issues
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
}
query = query.orderBy(`${aliased}.${key}`, direction, nulls) let composite = `${aliased}.${key}`
if (this.client === SqlClient.ORACLE) {
query = query.orderBy(
// @ts-ignore
this.knex.raw(this.convertClobs(composite)),
direction,
nulls
)
} else {
query = query.orderBy(composite, direction, nulls)
}
} }
} }
@ -732,17 +776,14 @@ class InternalBuilder {
return query return query
} }
qualifiedKnex( qualifiedKnex(opts?: { alias?: string | boolean }): Knex.QueryBuilder {
knex: Knex,
opts?: { alias?: string | boolean }
): Knex.QueryBuilder {
let alias = this.query.tableAliases?.[this.query.endpoint.entityId] let alias = this.query.tableAliases?.[this.query.endpoint.entityId]
if (opts?.alias === false) { if (opts?.alias === false) {
alias = undefined alias = undefined
} else if (typeof opts?.alias === "string") { } else if (typeof opts?.alias === "string") {
alias = opts.alias alias = opts.alias
} }
return knex( return this.knex(
this.tableNameWithSchema(this.query.endpoint.entityId, { this.tableNameWithSchema(this.query.endpoint.entityId, {
alias, alias,
schema: this.query.endpoint.schema, schema: this.query.endpoint.schema,
@ -750,9 +791,9 @@ class InternalBuilder {
) )
} }
create(knex: Knex, opts: QueryOptions): Knex.QueryBuilder { create(opts: QueryOptions): Knex.QueryBuilder {
const { body } = this.query const { body } = this.query
let query = this.qualifiedKnex(knex, { alias: false }) let query = this.qualifiedKnex({ alias: false })
const parsedBody = this.parseBody(body) const parsedBody = this.parseBody(body)
// make sure no null values in body for creation // make sure no null values in body for creation
for (let [key, value] of Object.entries(parsedBody)) { for (let [key, value] of Object.entries(parsedBody)) {
@ -769,9 +810,9 @@ class InternalBuilder {
} }
} }
bulkCreate(knex: Knex): Knex.QueryBuilder { bulkCreate(): Knex.QueryBuilder {
const { body } = this.query const { body } = this.query
let query = this.qualifiedKnex(knex, { alias: false }) let query = this.qualifiedKnex({ alias: false })
if (!Array.isArray(body)) { if (!Array.isArray(body)) {
return query return query
} }
@ -779,9 +820,9 @@ class InternalBuilder {
return query.insert(parsedBody) return query.insert(parsedBody)
} }
bulkUpsert(knex: Knex): Knex.QueryBuilder { bulkUpsert(): Knex.QueryBuilder {
const { body } = this.query const { body } = this.query
let query = this.qualifiedKnex(knex, { alias: false }) let query = this.qualifiedKnex({ alias: false })
if (!Array.isArray(body)) { if (!Array.isArray(body)) {
return query return query
} }
@ -809,7 +850,6 @@ class InternalBuilder {
} }
read( read(
knex: Knex,
opts: { opts: {
limits?: { base: number; query: number } limits?: { base: number; query: number }
} = {} } = {}
@ -821,7 +861,7 @@ class InternalBuilder {
const tableName = endpoint.entityId const tableName = endpoint.entityId
// start building the query // start building the query
let query = this.qualifiedKnex(knex) let query = this.qualifiedKnex()
// handle pagination // handle pagination
let foundOffset: number | null = null let foundOffset: number | null = null
let foundLimit = limits?.query || limits?.base let foundLimit = limits?.query || limits?.base
@ -855,7 +895,7 @@ class InternalBuilder {
query = this.addFilters(query, filters) query = this.addFilters(query, filters)
const alias = tableAliases?.[tableName] || tableName const alias = tableAliases?.[tableName] || tableName
let preQuery: Knex.QueryBuilder = knex({ let preQuery: Knex.QueryBuilder = this.knex({
// the typescript definition for the knex constructor doesn't support this // the typescript definition for the knex constructor doesn't support this
// syntax, but it is the only way to alias a pre-query result as part of // syntax, but it is the only way to alias a pre-query result as part of
// a query - there is an alias dictionary type, but it assumes it can only // a query - there is an alias dictionary type, but it assumes it can only
@ -864,7 +904,7 @@ class InternalBuilder {
}) })
// if counting, use distinct count, else select // if counting, use distinct count, else select
preQuery = !counting preQuery = !counting
? preQuery.select(this.generateSelectStatement(knex)) ? preQuery.select(this.generateSelectStatement())
: this.addDistinctCount(preQuery) : this.addDistinctCount(preQuery)
// have to add after as well (this breaks MS-SQL) // have to add after as well (this breaks MS-SQL)
if (this.client !== SqlClient.MS_SQL && !counting) { if (this.client !== SqlClient.MS_SQL && !counting) {
@ -888,9 +928,9 @@ class InternalBuilder {
return this.addFilters(query, filters, { relationship: true }) return this.addFilters(query, filters, { relationship: true })
} }
update(knex: Knex, opts: QueryOptions): Knex.QueryBuilder { update(opts: QueryOptions): Knex.QueryBuilder {
const { body, filters } = this.query const { body, filters } = this.query
let query = this.qualifiedKnex(knex) let query = this.qualifiedKnex()
const parsedBody = this.parseBody(body) const parsedBody = this.parseBody(body)
query = this.addFilters(query, filters) query = this.addFilters(query, filters)
// mysql can't use returning // mysql can't use returning
@ -901,15 +941,15 @@ class InternalBuilder {
} }
} }
delete(knex: Knex, opts: QueryOptions): Knex.QueryBuilder { delete(opts: QueryOptions): Knex.QueryBuilder {
const { filters } = this.query const { filters } = this.query
let query = this.qualifiedKnex(knex) let query = this.qualifiedKnex()
query = this.addFilters(query, filters) query = this.addFilters(query, filters)
// mysql can't use returning // mysql can't use returning
if (opts.disableReturning) { if (opts.disableReturning) {
return query.delete() return query.delete()
} else { } else {
return query.delete().returning(this.generateSelectStatement(knex)) return query.delete().returning(this.generateSelectStatement())
} }
} }
} }
@ -953,13 +993,13 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
const client = knex(config) const client = knex(config)
let query: Knex.QueryBuilder let query: Knex.QueryBuilder
const builder = new InternalBuilder(sqlClient, json) const builder = new InternalBuilder(sqlClient, client, json)
switch (this._operation(json)) { switch (this._operation(json)) {
case Operation.CREATE: case Operation.CREATE:
query = builder.create(client, opts) query = builder.create(opts)
break break
case Operation.READ: case Operation.READ:
query = builder.read(client, { query = builder.read({
limits: { limits: {
query: this.limit, query: this.limit,
base: BASE_LIMIT, base: BASE_LIMIT,
@ -968,19 +1008,19 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
break break
case Operation.COUNT: case Operation.COUNT:
// read without any limits to count // read without any limits to count
query = builder.read(client) query = builder.read()
break break
case Operation.UPDATE: case Operation.UPDATE:
query = builder.update(client, opts) query = builder.update(opts)
break break
case Operation.DELETE: case Operation.DELETE:
query = builder.delete(client, opts) query = builder.delete(opts)
break break
case Operation.BULK_CREATE: case Operation.BULK_CREATE:
query = builder.bulkCreate(client) query = builder.bulkCreate()
break break
case Operation.BULK_UPSERT: case Operation.BULK_UPSERT:
query = builder.bulkUpsert(client) query = builder.bulkUpsert()
break break
case Operation.CREATE_TABLE: case Operation.CREATE_TABLE:
case Operation.UPDATE_TABLE: case Operation.UPDATE_TABLE:

View File

@ -1,87 +0,0 @@
import { FieldType, Table, FieldSchema, SqlClient } from "@budibase/types"
import { Knex } from "knex"
export class SqlStatements {
client: string
table: Table
allOr: boolean | undefined
columnPrefix: string | undefined
constructor(
client: string,
table: Table,
{ allOr, columnPrefix }: { allOr?: boolean; columnPrefix?: string } = {}
) {
this.client = client
this.table = table
this.allOr = allOr
this.columnPrefix = columnPrefix
}
getField(key: string): FieldSchema | undefined {
const fieldName = key.split(".")[1]
let found = this.table.schema[fieldName]
if (!found && this.columnPrefix) {
const prefixRemovedFieldName = fieldName.replace(this.columnPrefix, "")
found = this.table.schema[prefixRemovedFieldName]
}
return found
}
between(
query: Knex.QueryBuilder,
key: string,
low: number | string,
high: number | string
) {
// Use a between operator if we have 2 valid range values
const field = this.getField(key)
if (
field?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(
`CAST(${key} AS INTEGER) BETWEEN CAST(? AS INTEGER) AND CAST(? AS INTEGER)`,
[low, high]
)
} else {
const fnc = this.allOr ? "orWhereBetween" : "whereBetween"
query = query[fnc](key, [low, high])
}
return query
}
lte(query: Knex.QueryBuilder, key: string, low: number | string) {
// Use just a single greater than operator if we only have a low
const field = this.getField(key)
if (
field?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(`CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`, [
low,
])
} else {
const fnc = this.allOr ? "orWhere" : "where"
query = query[fnc](key, ">=", low)
}
return query
}
gte(query: Knex.QueryBuilder, key: string, high: number | string) {
const field = this.getField(key)
// Use just a single less than operator if we only have a high
if (
field?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(`CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`, [
high,
])
} else {
const fnc = this.allOr ? "orWhere" : "where"
query = query[fnc](key, "<=", high)
}
return query
}
}

View File

@ -958,7 +958,7 @@ describe.each([
}).toMatchExactly([{ name: "bar" }, { name: "foo" }]) }).toMatchExactly([{ name: "bar" }, { name: "foo" }])
}) })
it.only("sorts descending", async () => { it("sorts descending", async () => {
await expectSearch({ await expectSearch({
query: {}, query: {},
sort: "name", sort: "name",