budibase/packages/backend-core/src/sql/sql.ts

1222 lines
37 KiB
TypeScript
Raw Normal View History

import { Knex, knex } from "knex"
import * as dbCore from "../db"
import {
getNativeSql,
isExternalTable,
2024-07-30 19:22:20 +02:00
isInvalidISODateString,
isValidFilter,
2024-07-30 19:22:20 +02:00
isValidISODateString,
sqlLog,
} from "./utils"
import SqlTableQueryBuilder from "./sqlTable"
2021-06-24 19:17:26 +02:00
import {
2024-07-09 14:01:44 +02:00
AnySearchFilter,
2024-07-30 12:03:54 +02:00
ArrayOperator,
BasicOperator,
BBReferenceFieldMetadata,
2024-03-12 16:27:34 +01:00
FieldSchema,
FieldType,
INTERNAL_TABLE_SOURCE_ID,
2024-07-09 12:24:59 +02:00
InternalSearchFilterOperator,
JsonFieldMetadata,
JsonTypes,
Operation,
prefixed,
2021-06-24 19:17:26 +02:00
QueryJson,
QueryOptions,
2024-07-30 12:03:54 +02:00
RangeOperator,
RelationshipsJson,
2021-10-28 20:39:42 +02:00
SearchFilters,
SortOrder,
SqlClient,
SqlQuery,
SqlQueryBinding,
2024-03-12 16:27:34 +01:00
Table,
TableSourceType,
} from "@budibase/types"
import environment from "../environment"
2024-07-30 12:03:54 +02:00
import { dataFilters, helpers } from "@budibase/shared-core"
2021-10-28 20:39:42 +02:00
2024-04-04 19:16:23 +02:00
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
2024-03-04 16:47:27 +01:00
function getBaseLimit() {
const envLimit = environment.SQL_MAX_ROWS
? parseInt(environment.SQL_MAX_ROWS)
: null
return envLimit || 5000
}
2024-04-16 12:38:00 +02:00
function getTableName(table?: Table): string | undefined {
// SQS uses the table ID rather than the table name
if (
2024-04-16 12:38:00 +02:00
table?.sourceType === TableSourceType.INTERNAL ||
table?.sourceId === INTERNAL_TABLE_SOURCE_ID
) {
2024-04-16 12:38:00 +02:00
return table?._id
} else {
2024-04-16 12:38:00 +02:00
return table?.name
}
}
function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
if (Array.isArray(query)) {
return query.map((q: SqlQuery) => convertBooleans(q) as SqlQuery)
} else {
if (query.bindings) {
query.bindings = query.bindings.map(binding => {
if (typeof binding === "boolean") {
return binding ? 1 : 0
}
return binding
})
}
}
return query
}
class InternalBuilder {
2024-07-17 16:45:35 +02:00
private readonly client: SqlClient
private readonly query: QueryJson
2024-07-30 12:03:54 +02:00
private readonly splitter: dataFilters.ColumnSplitter
2024-07-30 12:54:46 +02:00
private readonly knex: Knex
2024-07-30 12:54:46 +02:00
constructor(client: SqlClient, knex: Knex, query: QueryJson) {
this.client = client
this.query = query
2024-07-30 12:54:46 +02:00
this.knex = knex
2024-07-30 12:03:54 +02:00
this.splitter = new dataFilters.ColumnSplitter([this.table], {
aliases: this.query.tableAliases,
columnPrefix: this.query.meta.columnPrefix,
})
}
get table(): Table {
return this.query.meta.table
}
2024-07-30 12:54:46 +02:00
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
// and "foo" for Postgres.
private quote(str: string): string {
switch (this.client) {
case SqlClient.SQL_LITE:
case SqlClient.ORACLE:
case SqlClient.POSTGRES:
return `"${str}"`
case SqlClient.MS_SQL:
return `[${str}]`
case SqlClient.MY_SQL:
return `\`${str}\``
}
}
// 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.
private quotedIdentifier(key: string): string {
return key
.split(".")
.map(part => this.quote(part))
.join(".")
}
2024-07-30 12:54:46 +02:00
private generateSelectStatement(): (string | Knex.Raw)[] | "*" {
const { resource, meta } = this.query
if (!resource || !resource.fields || resource.fields.length === 0) {
return "*"
}
const schema = meta.table.schema
return resource.fields.map(field => {
const parts = field.split(/\./g)
let table: string | undefined = undefined
let column: string | undefined = undefined
// Just a column name, e.g.: "column"
if (parts.length === 1) {
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 (
2024-07-30 12:54:46 +02:00
this.client === SqlClient.POSTGRES &&
columnSchema?.externalType?.includes("money")
) {
2024-07-30 12:54:46 +02:00
return this.knex.raw(
`${this.quotedIdentifier(
[table, column].join(".")
)}::money::numeric as ${this.quote(field)}`
)
}
if (
2024-07-30 12:54:46 +02:00
this.client === SqlClient.MS_SQL &&
columnSchema?.type === FieldType.DATETIME &&
columnSchema.timeOnly
) {
// Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format
2024-07-30 12:54:46 +02:00
return this.knex.raw(`CONVERT(varchar, ${field}, 108) 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) {
2024-07-30 12:54:46 +02:00
return this.knex.raw(
`${this.quote(table)}.${this.quote(column)} as ${this.quote(field)}`
)
} else {
2024-07-30 12:54:46 +02:00
return this.knex.raw(`${this.quote(field)} as ${this.quote(field)}`)
}
})
}
// OracleDB can't use character-large-objects (CLOBs) in WHERE clauses,
// so when we use them we need to wrap them in to_char(). This function
// converts a field name to the appropriate identifier.
private convertClobs(field: string): string {
const parts = field.split(".")
const col = parts.pop()!
const schema = this.table.schema[col]
let identifier = this.quotedIdentifier(field)
if (
schema.type === FieldType.STRING ||
schema.type === FieldType.LONGFORM ||
schema.type === FieldType.BB_REFERENCE_SINGLE ||
2024-07-30 18:41:39 +02:00
schema.type === FieldType.BB_REFERENCE ||
schema.type === FieldType.OPTIONS ||
schema.type === FieldType.BARCODEQR
) {
identifier = `to_char(${identifier})`
}
return identifier
}
2024-07-30 12:03:54 +02:00
private parse(input: any, schema: FieldSchema) {
if (Array.isArray(input)) {
return JSON.stringify(input)
}
if (input == undefined) {
return null
}
2024-07-30 12:26:16 +02:00
if (
this.client === SqlClient.ORACLE &&
schema.type === FieldType.DATETIME &&
schema.timeOnly
) {
if (input instanceof Date) {
const hours = input.getHours().toString().padStart(2, "0")
const minutes = input.getMinutes().toString().padStart(2, "0")
const seconds = input.getSeconds().toString().padStart(2, "0")
return `${hours}:${minutes}:${seconds}`
}
if (typeof input === "string") {
2024-07-30 17:03:03 +02:00
return new Date(`1970-01-01T${input}Z`)
2024-07-30 12:26:16 +02:00
}
}
2024-07-30 12:03:54 +02:00
if (typeof input === "string") {
if (isInvalidISODateString(input)) {
return null
}
if (isValidISODateString(input)) {
return new Date(input.trim())
}
}
return input
}
private parseBody(body: any) {
for (let [key, value] of Object.entries(body)) {
2024-07-30 12:03:54 +02:00
const { column } = this.splitter.run(key)
const schema = this.table.schema[column]
if (!schema) {
continue
}
body[key] = this.parse(value, schema)
}
return body
}
2024-07-30 12:03:54 +02:00
private parseFilters(filters: SearchFilters): SearchFilters {
for (const op of Object.values(BasicOperator)) {
const filter = filters[op]
if (!filter) {
continue
}
for (const key of Object.keys(filter)) {
if (Array.isArray(filter[key])) {
filter[key] = JSON.stringify(filter[key])
continue
}
const { column } = this.splitter.run(key)
const schema = this.table.schema[column]
if (!schema) {
continue
}
filter[key] = this.parse(filter[key], schema)
}
}
2024-07-30 12:03:54 +02:00
for (const op of Object.values(ArrayOperator)) {
const filter = filters[op]
if (!filter) {
continue
}
for (const key of Object.keys(filter)) {
const { column } = this.splitter.run(key)
const schema = this.table.schema[column]
if (!schema) {
continue
}
filter[key] = filter[key].map(v => this.parse(v, schema))
}
}
2024-07-30 12:03:54 +02:00
for (const op of Object.values(RangeOperator)) {
const filter = filters[op]
if (!filter) {
continue
}
for (const key of Object.keys(filter)) {
const { column } = this.splitter.run(key)
const schema = this.table.schema[column]
if (!schema) {
continue
}
const value = filter[key]
if ("low" in value) {
value.low = this.parse(value.low, schema)
}
if ("high" in value) {
value.high = this.parse(value.high, schema)
}
}
}
2024-07-30 12:03:54 +02:00
return filters
}
// right now we only do filters on the specific table being queried
addFilters(
query: Knex.QueryBuilder,
filters: SearchFilters | undefined,
opts?: {
relationship?: boolean
}
): Knex.QueryBuilder {
if (!filters) {
return query
}
filters = this.parseFilters(filters)
const aliases = this.query.tableAliases
// if all or specified in filters, then everything is an or
const allOr = filters.allOr
2024-04-16 18:45:06 +02:00
const tableName =
this.client === SqlClient.SQL_LITE ? this.table._id! : this.table.name
2024-04-16 18:45:06 +02:00
function getTableAlias(name: string) {
const alias = aliases?.[name]
2024-01-30 18:57:10 +01:00
return alias || name
}
function iterate(
2024-07-09 14:01:44 +02:00
structure: AnySearchFilter,
fn: (key: string, value: any) => void,
complexKeyFn?: (key: string[], value: any) => void
) {
2024-07-09 14:01:44 +02:00
for (const key in structure) {
const value = structure[key]
const updatedKey = dbCore.removeKeyNumbering(key)
const isRelationshipField = updatedKey.includes(".")
2024-07-09 10:43:45 +02:00
2024-07-09 14:01:44 +02:00
let castedTypeValue
if (
key === InternalSearchFilterOperator.COMPLEX_ID_OPERATOR &&
(castedTypeValue = structure[key]) &&
complexKeyFn
) {
2024-07-09 10:43:45 +02:00
const alias = getTableAlias(tableName)
2024-07-09 14:01:44 +02:00
complexKeyFn(
castedTypeValue.id.map((x: string) =>
alias ? `${alias}.${x}` : x
),
castedTypeValue.values
2024-07-09 10:43:45 +02:00
)
} else if (!opts?.relationship && !isRelationshipField) {
const alias = getTableAlias(tableName)
fn(alias ? `${alias}.${updatedKey}` : updatedKey, value)
} else if (opts?.relationship && isRelationshipField) {
2024-01-30 18:57:10 +01:00
const [filterTableName, property] = updatedKey.split(".")
const alias = getTableAlias(filterTableName)
fn(alias ? `${alias}.${property}` : property, value)
}
}
}
const like = (key: string, value: any) => {
const fuzzyOr = filters?.fuzzyOr
const fnc = fuzzyOr || allOr ? "orWhere" : "where"
// postgres supports ilike, nothing else does
if (this.client === SqlClient.POSTGRES) {
query = query[fnc](key, "ilike", `%${value}%`)
} else {
const rawFnc = `${fnc}Raw`
// @ts-ignore
query = query[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
`%${value.toLowerCase()}%`,
])
}
}
2024-07-09 14:01:44 +02:00
const contains = (mode: AnySearchFilter, any: boolean = false) => {
2024-05-20 18:01:52 +02:00
const rawFnc = allOr ? "orWhereRaw" : "whereRaw"
2022-07-27 12:40:46 +02:00
const not = mode === filters?.notContains ? "NOT " : ""
2022-07-27 17:37:29 +02:00
function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
2022-07-27 12:40:46 +02:00
for (let i in value) {
if (typeof value[i] === "string") {
2022-07-27 17:37:29 +02:00
value[i] = `${quoteStyle}${value[i]}${quoteStyle}`
2022-07-27 12:40:46 +02:00
}
}
2022-07-27 17:37:29 +02:00
return `[${value.join(",")}]`
2022-07-27 12:40:46 +02:00
}
if (this.client === SqlClient.POSTGRES) {
2024-07-09 14:01:44 +02:00
iterate(mode, (key, value) => {
2022-07-27 17:37:29 +02:00
const wrap = any ? "" : "'"
2024-05-20 18:01:52 +02:00
const op = any ? "\\?| array" : "@>"
2022-07-27 12:40:46 +02:00
const fieldNames = key.split(/\./g)
2024-05-20 18:01:52 +02:00
const table = fieldNames[0]
const col = fieldNames[1]
2022-07-27 17:40:07 +02:00
query = query[rawFnc](
2024-05-20 18:01:52 +02:00
`${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray(
2022-07-27 17:40:07 +02:00
value,
any ? "'" : '"'
2024-05-20 18:01:52 +02:00
)}${wrap}, FALSE)`
2022-07-27 12:40:46 +02:00
)
})
} else if (this.client === SqlClient.MY_SQL) {
2022-07-27 14:19:47 +02:00
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
2024-07-09 14:01:44 +02:00
iterate(mode, (key, value) => {
2022-07-27 12:40:46 +02:00
query = query[rawFnc](
2024-05-20 18:01:52 +02:00
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
value
)}'), FALSE)`
2022-07-27 12:40:46 +02:00
)
})
} else {
2022-07-28 10:20:00 +02:00
const andOr = mode === filters?.containsAny ? " OR " : " AND "
2024-07-09 14:01:44 +02:00
iterate(mode, (key, value) => {
2022-07-28 10:20:00 +02:00
let statement = ""
2024-07-30 19:22:20 +02:00
const identifier = this.quotedIdentifier(key)
2022-07-27 12:40:46 +02:00
for (let i in value) {
if (typeof value[i] === "string") {
value[i] = `%"${value[i].toLowerCase()}"%`
2022-07-27 12:40:46 +02:00
} else {
value[i] = `%${value[i]}%`
}
2024-07-30 19:22:20 +02:00
statement += `${
statement ? andOr : ""
}COALESCE(LOWER(${identifier}), '') LIKE ?`
2022-07-27 12:40:46 +02:00
}
if (statement === "") {
return
}
2024-07-30 19:22:20 +02:00
if (not) {
query = query[rawFnc](
`(NOT (${statement}) OR ${identifier} IS NULL)`,
value
)
} else {
query = query[rawFnc](statement, value)
}
2022-07-27 12:40:46 +02:00
})
}
}
2024-08-05 12:33:44 +02:00
if (filters.$and) {
2024-08-05 15:09:33 +02:00
const { $and } = filters
query = query.where(x => {
for (const condition of $and.conditions) {
2024-08-05 17:32:40 +02:00
x = this.addFilters(x, condition, opts)
2024-08-05 15:09:33 +02:00
}
2024-08-05 12:33:44 +02:00
})
}
if (filters.$or) {
const { $or } = filters
query = query.where(x => {
for (const condition of $or.conditions) {
2024-08-05 17:32:40 +02:00
x = this.addFilters(x, { ...condition, allOr: true }, opts)
2024-08-05 12:33:44 +02:00
}
})
}
if (filters.oneOf) {
2024-07-09 14:01:44 +02:00
const fnc = allOr ? "orWhereIn" : "whereIn"
iterate(
filters.oneOf,
(key: string, array) => {
2024-07-29 10:57:24 +02:00
if (this.client === SqlClient.ORACLE) {
key = this.convertClobs(key)
array = Array.isArray(array) ? array : [array]
const binding = new Array(array.length).fill("?").join(",")
query = query.whereRaw(`${key} IN (${binding})`, array)
2024-07-29 10:57:24 +02:00
} else {
query = query[fnc](key, Array.isArray(array) ? array : [array])
}
2024-07-09 14:01:44 +02:00
},
(key: string[], array) => {
2024-08-02 18:17:33 +02:00
if (this.client === SqlClient.ORACLE) {
const keyStr = `(${key.map(k => this.convertClobs(k)).join(",")})`
const binding = `(${array
.map((a: any) => `(${new Array(a.length).fill("?").join(",")})`)
.join(",")})`
query = query.whereRaw(`${keyStr} IN ${binding}`, array.flat())
} else {
query = query[fnc](key, Array.isArray(array) ? array : [array])
}
2024-07-09 14:01:44 +02:00
}
)
}
if (filters.string) {
iterate(filters.string, (key, value) => {
const fnc = allOr ? "orWhere" : "where"
// postgres supports ilike, nothing else does
if (this.client === SqlClient.POSTGRES) {
query = query[fnc](key, "ilike", `${value}%`)
} else {
const rawFnc = `${fnc}Raw`
// @ts-ignore
query = query[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
`${value.toLowerCase()}%`,
])
}
})
}
if (filters.fuzzy) {
iterate(filters.fuzzy, like)
}
if (filters.range) {
iterate(filters.range, (key, value) => {
2023-02-15 16:10:02 +01:00
const isEmptyObject = (val: any) => {
return (
val &&
Object.keys(val).length === 0 &&
Object.getPrototypeOf(val) === Object.prototype
)
}
if (isEmptyObject(value.low)) {
value.low = ""
}
if (isEmptyObject(value.high)) {
value.high = ""
}
const lowValid = isValidFilter(value.low),
highValid = isValidFilter(value.high)
2024-07-30 12:54:46 +02:00
const schema = this.getFieldSchema(key)
if (this.client === SqlClient.ORACLE) {
// @ts-ignore
key = this.knex.raw(this.convertClobs(key))
}
if (lowValid && highValid) {
2024-07-30 12:54:46 +02:00
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) {
2024-07-30 12:54:46 +02:00
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) {
2024-07-30 12:54:46 +02:00
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)
}
}
})
}
if (filters.equal) {
iterate(filters.equal, (key, value) => {
2024-05-20 18:01:52 +02:00
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${this.quotedIdentifier(key)} = ? THEN 1 ELSE 0 END = 1`,
2024-05-20 18:01:52 +02:00
[value]
)
2024-07-23 10:44:58 +02:00
} else if (this.client === SqlClient.ORACLE) {
const identifier = this.convertClobs(key)
2024-07-23 12:39:50 +02:00
query = query[fnc](
2024-07-29 10:57:24 +02:00
`(${identifier} IS NOT NULL AND ${identifier} = ?)`,
2024-07-23 12:39:50 +02:00
[value]
)
2024-05-20 18:01:52 +02:00
} else {
query = query[fnc](
`COALESCE(${this.quotedIdentifier(key)} = ?, FALSE)`,
2024-05-20 18:01:52 +02:00
[value]
)
}
})
}
if (filters.notEqual) {
iterate(filters.notEqual, (key, value) => {
2024-05-20 18:01:52 +02:00
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${this.quotedIdentifier(key)} = ? THEN 1 ELSE 0 END = 0`,
2024-05-20 18:01:52 +02:00
[value]
)
2024-07-23 10:44:58 +02:00
} else if (this.client === SqlClient.ORACLE) {
const identifier = this.convertClobs(key)
2024-07-23 12:39:50 +02:00
query = query[fnc](
2024-07-30 12:26:16 +02:00
`(${identifier} IS NOT NULL AND ${identifier} != ?) OR ${identifier} IS NULL`,
2024-07-23 12:39:50 +02:00
[value]
)
2024-05-20 18:01:52 +02:00
} else {
query = query[fnc](
`COALESCE(${this.quotedIdentifier(key)} != ?, TRUE)`,
2024-05-20 18:01:52 +02:00
[value]
)
}
})
}
if (filters.empty) {
iterate(filters.empty, key => {
const fnc = allOr ? "orWhereNull" : "whereNull"
query = query[fnc](key)
})
}
if (filters.notEmpty) {
iterate(filters.notEmpty, key => {
const fnc = allOr ? "orWhereNotNull" : "whereNotNull"
query = query[fnc](key)
})
}
if (filters.contains) {
2022-07-27 12:40:46 +02:00
contains(filters.contains)
}
if (filters.notContains) {
contains(filters.notContains)
}
2022-07-27 14:19:47 +02:00
if (filters.containsAny) {
contains(filters.containsAny, true)
}
const tableRef = aliases?.[this.table._id!] || this.table._id
// when searching internal tables make sure long looking for rows
if (filters.documentType && !isExternalTable(this.table) && tableRef) {
// has to be its own option, must always be AND onto the search
query.andWhereLike(
`${tableRef}._id`,
`${prefixed(filters.documentType)}%`
)
}
2021-06-23 20:05:32 +02:00
return query
}
addDistinctCount(query: Knex.QueryBuilder): Knex.QueryBuilder {
const primary = this.table.primary
const aliases = this.query.tableAliases
const aliased =
this.table.name && aliases?.[this.table.name]
? aliases[this.table.name]
: this.table.name
if (!primary) {
throw new Error("SQL counting requires primary key to be supplied")
}
return query.countDistinct(`${aliased}.${primary[0]} as total`)
}
addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder {
let { sort } = this.query
const primaryKey = this.table.primary
const tableName = getTableName(this.table)
const aliases = this.query.tableAliases
const aliased =
tableName && aliases?.[tableName] ? aliases[tableName] : this.table?.name
if (!Array.isArray(primaryKey)) {
throw new Error("Sorting requires primary key to be specified for table")
}
if (sort && Object.keys(sort || {}).length > 0) {
for (let [key, value] of Object.entries(sort)) {
const direction =
value.direction === SortOrder.ASCENDING ? "asc" : "desc"
2024-07-30 12:58:28 +02:00
2024-08-05 17:15:15 +02:00
// TODO: figure out a way to remove this conditional, not relying on
// the defaults of each datastore.
2024-07-30 12:58:28 +02:00
let nulls: "first" | "last" | undefined = undefined
if (
2024-07-30 17:56:59 +02:00
this.client === SqlClient.POSTGRES ||
this.client === SqlClient.ORACLE
2024-07-30 12:58:28 +02:00
) {
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
}
2024-05-21 16:08:22 +02:00
2024-07-30 12:54:46 +02:00
let composite = `${aliased}.${key}`
if (this.client === SqlClient.ORACLE) {
2024-07-30 17:56:59 +02:00
query = query.orderByRaw(
`${this.convertClobs(composite)} ${direction} nulls ${nulls}`
2024-07-30 12:54:46 +02:00
)
} else {
query = query.orderBy(composite, direction, nulls)
}
}
}
2024-06-24 18:30:10 +02:00
// add sorting by the primary key if the result isn't already sorted by it,
// to make sure result is deterministic
if (!sort || sort[primaryKey[0]] === undefined) {
query = query.orderBy(`${aliased}.${primaryKey[0]}`)
}
return query
}
2023-11-28 19:43:38 +01:00
tableNameWithSchema(
tableName: string,
opts?: { alias?: string; schema?: string }
) {
let withSchema = opts?.schema ? `${opts.schema}.${tableName}` : tableName
if (opts?.alias) {
withSchema += ` as ${opts.alias}`
}
return withSchema
}
addRelationships(
query: Knex.QueryBuilder,
fromTable: string,
relationships: RelationshipsJson[] | undefined,
2024-01-30 18:57:10 +01:00
schema: string | undefined,
aliases?: Record<string, string>
): Knex.QueryBuilder {
if (!relationships) {
return query
}
2023-11-28 19:43:38 +01:00
const tableSets: Record<string, [RelationshipsJson]> = {}
// aggregate into table sets (all the same to tables)
for (let relationship of relationships) {
const keyObj: { toTable: string; throughTable: string | undefined } = {
toTable: relationship.tableName,
throughTable: undefined,
}
if (relationship.through) {
keyObj.throughTable = relationship.through
}
const key = JSON.stringify(keyObj)
if (tableSets[key]) {
tableSets[key].push(relationship)
} else {
tableSets[key] = [relationship]
}
}
for (let [key, relationships] of Object.entries(tableSets)) {
const { toTable, throughTable } = JSON.parse(key)
2024-01-30 18:57:10 +01:00
const toAlias = aliases?.[toTable] || toTable,
throughAlias = aliases?.[throughTable] || throughTable,
fromAlias = aliases?.[fromTable] || fromTable
2023-11-28 19:43:38 +01:00
let toTableWithSchema = this.tableNameWithSchema(toTable, {
alias: toAlias,
schema,
})
let throughTableWithSchema = this.tableNameWithSchema(throughTable, {
alias: throughAlias,
schema,
})
if (!throughTable) {
// @ts-ignore
query = query.leftJoin(toTableWithSchema, function () {
for (let relationship of relationships) {
const from = relationship.from,
to = relationship.to
// @ts-ignore
2023-11-28 19:45:05 +01:00
this.orOn(`${fromAlias}.${from}`, "=", `${toAlias}.${to}`)
}
})
} else {
query = query
2022-02-02 19:18:53 +01:00
// @ts-ignore
.leftJoin(throughTableWithSchema, function () {
for (let relationship of relationships) {
const fromPrimary = relationship.fromPrimary
const from = relationship.from
// @ts-ignore
this.orOn(
2023-11-28 19:43:38 +01:00
`${fromAlias}.${fromPrimary}`,
"=",
2023-11-28 19:43:38 +01:00
`${throughAlias}.${from}`
)
}
})
.leftJoin(toTableWithSchema, function () {
for (let relationship of relationships) {
const toPrimary = relationship.toPrimary
const to = relationship.to
// @ts-ignore
2023-11-28 19:43:38 +01:00
this.orOn(`${toAlias}.${toPrimary}`, `${throughAlias}.${to}`)
}
})
}
2021-06-23 20:05:32 +02:00
}
return query
2021-06-23 20:05:32 +02:00
}
2024-07-30 12:54:46 +02:00
qualifiedKnex(opts?: { alias?: string | boolean }): Knex.QueryBuilder {
let alias = this.query.tableAliases?.[this.query.endpoint.entityId]
if (opts?.alias === false) {
alias = undefined
} else if (typeof opts?.alias === "string") {
alias = opts.alias
}
2024-07-30 12:54:46 +02:00
return this.knex(
this.tableNameWithSchema(this.query.endpoint.entityId, {
alias,
schema: this.query.endpoint.schema,
2024-04-18 16:40:43 +02:00
})
)
}
2024-07-30 12:54:46 +02:00
create(opts: QueryOptions): Knex.QueryBuilder {
const { body } = this.query
2024-07-30 12:54:46 +02:00
let query = this.qualifiedKnex({ alias: false })
const parsedBody = this.parseBody(body)
2024-08-02 17:58:12 +02:00
if (this.client === SqlClient.ORACLE) {
// Oracle doesn't seem to automatically insert nulls
// if we don't specify them, so we need to do that here
for (const [column, schema] of Object.entries(
this.query.meta.table.schema
)) {
if (
schema.constraints?.presence === true ||
schema.type === FieldType.FORMULA ||
schema.type === FieldType.AUTO ||
schema.type === FieldType.LINK
) {
continue
}
const value = parsedBody[column]
if (value == null) {
parsedBody[column] = null
}
}
} else {
// make sure no null values in body for creation
for (let [key, value] of Object.entries(parsedBody)) {
if (value == null) {
delete parsedBody[key]
}
}
}
2023-02-06 21:47:49 +01:00
// mysql can't use returning
if (opts.disableReturning) {
return query.insert(parsedBody)
} else {
2023-02-23 10:28:24 +01:00
return query.insert(parsedBody).returning("*")
}
}
2024-07-30 12:54:46 +02:00
bulkCreate(): Knex.QueryBuilder {
const { body } = this.query
2024-07-30 12:54:46 +02:00
let query = this.qualifiedKnex({ alias: false })
if (!Array.isArray(body)) {
return query
}
const parsedBody = body.map(row => this.parseBody(row))
return query.insert(parsedBody)
}
2024-07-30 12:54:46 +02:00
bulkUpsert(): Knex.QueryBuilder {
const { body } = this.query
2024-07-30 12:54:46 +02:00
let query = this.qualifiedKnex({ alias: false })
if (!Array.isArray(body)) {
return query
}
const parsedBody = body.map(row => this.parseBody(row))
if (
this.client === SqlClient.POSTGRES ||
this.client === SqlClient.SQL_LITE ||
this.client === SqlClient.MY_SQL
) {
const primary = this.table.primary
if (!primary) {
throw new Error("Primary key is required for upsert")
}
const ret = query.insert(parsedBody).onConflict(primary).merge()
return ret
2024-07-29 10:57:24 +02:00
} else if (
this.client === SqlClient.MS_SQL ||
this.client === SqlClient.ORACLE
) {
// No upsert or onConflict support in MSSQL/Oracle yet, see:
// https://github.com/knex/knex/pull/6050
return query.insert(parsedBody)
}
return query.upsert(parsedBody)
}
read(
opts: {
limits?: { base: number; query: number }
} = {}
): Knex.QueryBuilder {
let { endpoint, filters, paginate, relationships, tableAliases } =
this.query
const { limits } = opts
const counting = endpoint.operation === Operation.COUNT
const tableName = endpoint.entityId
// start building the query
2024-07-30 12:54:46 +02:00
let query = this.qualifiedKnex()
// handle pagination
let foundOffset: number | null = null
let foundLimit = limits?.query || limits?.base
if (paginate && paginate.page && paginate.limit) {
// @ts-ignore
const page = paginate.page <= 1 ? 0 : paginate.page - 1
const offset = page * paginate.limit
foundLimit = paginate.limit
foundOffset = offset
} else if (paginate && paginate.offset && paginate.limit) {
foundLimit = paginate.limit
foundOffset = paginate.offset
} else if (paginate && paginate.limit) {
foundLimit = paginate.limit
}
2024-06-19 19:46:48 +02:00
// counting should not sort, limit or offset
if (!counting) {
// add the found limit if supplied
if (foundLimit != null) {
query = query.limit(foundLimit)
}
// add overall pagination
if (foundOffset != null) {
query = query.offset(foundOffset)
}
// add sorting to pre-query
// no point in sorting when counting
query = this.addSorting(query)
}
// add filters to the query (where)
query = this.addFilters(query, filters)
2024-01-30 18:57:10 +01:00
const alias = tableAliases?.[tableName] || tableName
2024-07-30 12:54:46 +02:00
let preQuery: Knex.QueryBuilder = this.knex({
2024-06-19 14:59:03 +02:00
// 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
// a query - there is an alias dictionary type, but it assumes it can only
// be a table name, not a pre-query
[alias]: query as any,
})
2024-06-19 19:46:48 +02:00
// if counting, use distinct count, else select
preQuery = !counting
2024-07-30 12:54:46 +02:00
? preQuery.select(this.generateSelectStatement())
: this.addDistinctCount(preQuery)
// have to add after as well (this breaks MS-SQL)
if (this.client !== SqlClient.MS_SQL && !counting) {
preQuery = this.addSorting(preQuery)
}
// handle joins
query = this.addRelationships(
preQuery,
tableName,
relationships,
2024-01-30 18:57:10 +01:00
endpoint.schema,
tableAliases
)
2024-06-19 12:52:50 +02:00
// add a base limit over the whole query
// if counting we can't set this limit
if (limits?.base) {
query = query.limit(limits.base)
}
return this.addFilters(query, filters, { relationship: true })
}
2024-07-30 12:54:46 +02:00
update(opts: QueryOptions): Knex.QueryBuilder {
const { body, filters } = this.query
2024-07-30 12:54:46 +02:00
let query = this.qualifiedKnex()
const parsedBody = this.parseBody(body)
query = this.addFilters(query, filters)
// mysql can't use returning
if (opts.disableReturning) {
return query.update(parsedBody)
} else {
2023-02-22 11:54:55 +01:00
return query.update(parsedBody).returning("*")
}
}
2024-07-30 12:54:46 +02:00
delete(opts: QueryOptions): Knex.QueryBuilder {
const { filters } = this.query
2024-07-30 12:54:46 +02:00
let query = this.qualifiedKnex()
query = this.addFilters(query, filters)
// mysql can't use returning
if (opts.disableReturning) {
return query.delete()
} else {
2024-07-30 12:54:46 +02:00
return query.delete().returning(this.generateSelectStatement())
}
}
}
2021-10-28 20:39:42 +02:00
class SqlQueryBuilder extends SqlTableQueryBuilder {
private readonly limit: number
2024-07-09 10:43:45 +02:00
// pass through client to get flavour of SQL
constructor(client: SqlClient, limit: number = getBaseLimit()) {
2021-10-28 20:39:42 +02:00
super(client)
this.limit = limit
}
private convertToNative(query: Knex.QueryBuilder, opts: QueryOptions = {}) {
const sqlClient = this.getSqlClient()
if (opts?.disableBindings) {
return { sql: query.toString() }
} else {
let native = getNativeSql(query)
if (sqlClient === SqlClient.SQL_LITE) {
native = convertBooleans(native)
}
return native
}
}
/**
* @param json The JSON query DSL which is to be converted to SQL.
* @param opts extra options which are to be passed into the query builder, e.g. disableReturning
* which for the sake of mySQL stops adding the returning statement to inserts, updates and deletes.
* @return the query ready to be passed to the driver.
*/
2024-04-04 19:16:23 +02:00
_query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] {
2021-10-28 20:39:42 +02:00
const sqlClient = this.getSqlClient()
2024-05-20 16:34:22 +02:00
const config: Knex.Config = {
client: sqlClient,
}
2024-07-29 10:57:24 +02:00
if (sqlClient === SqlClient.SQL_LITE || sqlClient === SqlClient.ORACLE) {
config.useNullAsDefault = true
}
2024-05-20 16:34:22 +02:00
const client = knex(config)
let query: Knex.QueryBuilder
2024-07-30 12:54:46 +02:00
const builder = new InternalBuilder(sqlClient, client, json)
2021-06-03 19:48:04 +02:00
switch (this._operation(json)) {
case Operation.CREATE:
2024-07-30 12:54:46 +02:00
query = builder.create(opts)
break
case Operation.READ:
2024-07-30 12:54:46 +02:00
query = builder.read({
limits: {
query: this.limit,
base: getBaseLimit(),
},
})
break
case Operation.COUNT:
// read without any limits to count
2024-07-30 12:54:46 +02:00
query = builder.read()
break
case Operation.UPDATE:
2024-07-30 12:54:46 +02:00
query = builder.update(opts)
break
case Operation.DELETE:
2024-07-30 12:54:46 +02:00
query = builder.delete(opts)
break
case Operation.BULK_CREATE:
2024-07-30 12:54:46 +02:00
query = builder.bulkCreate()
break
case Operation.BULK_UPSERT:
2024-07-30 12:54:46 +02:00
query = builder.bulkUpsert()
break
case Operation.CREATE_TABLE:
case Operation.UPDATE_TABLE:
case Operation.DELETE_TABLE:
2021-10-28 20:39:42 +02:00
return this._tableQuery(json)
default:
throw `Operation type is not supported by SQL query builder`
}
return this.convertToNative(query, opts)
}
2024-03-04 16:47:27 +01:00
async getReturningRow(queryFn: QueryFunction, json: QueryJson) {
if (!json.extra || !json.extra.idFilter) {
return {}
}
const input = this._query({
endpoint: {
...json.endpoint,
operation: Operation.READ,
},
resource: {
fields: [],
},
2024-03-04 16:47:27 +01:00
filters: json.extra?.idFilter,
paginate: {
limit: 1,
},
meta: json.meta,
})
return queryFn(input, Operation.READ)
}
// when creating if an ID has been inserted need to make sure
// the id filter is enriched with it before trying to retrieve the row
checkLookupKeys(id: any, json: QueryJson) {
2024-04-16 18:39:05 +02:00
if (!id || !json.meta.table || !json.meta.table.primary) {
return json
}
const primaryKey = json.meta.table.primary?.[0]
json.extra = {
idFilter: {
equal: {
[primaryKey]: id,
},
},
}
return json
}
// this function recreates the returning functionality of postgres
async queryWithReturning(
json: QueryJson,
2024-03-04 16:47:27 +01:00
queryFn: QueryFunction,
processFn: Function = (result: any) => result
) {
const sqlClient = this.getSqlClient()
const operation = this._operation(json)
const input = this._query(json, { disableReturning: true })
if (Array.isArray(input)) {
const responses = []
for (let query of input) {
responses.push(await queryFn(query, operation))
}
return responses
}
let row
// need to manage returning, a feature mySQL can't do
if (operation === Operation.DELETE) {
row = processFn(await this.getReturningRow(queryFn, json))
}
const response = await queryFn(input, operation)
const results = processFn(response)
// same as delete, manage returning
if (operation === Operation.CREATE || operation === Operation.UPDATE) {
let id
if (sqlClient === SqlClient.MS_SQL) {
id = results?.[0].id
} else if (sqlClient === SqlClient.MY_SQL) {
id = results?.insertId
}
row = processFn(
await this.getReturningRow(queryFn, this.checkLookupKeys(id, json))
)
}
if (operation === Operation.COUNT) {
return results
}
if (operation !== Operation.READ) {
return row
}
return results.length ? results : [{ [operation.toLowerCase()]: true }]
}
2024-01-18 19:13:11 +01:00
convertJsonStringColumns<T extends Record<string, any>>(
2024-03-12 16:27:34 +01:00
table: Table,
results: T[],
aliases?: Record<string, string>
): T[] {
const tableName = getTableName(table)
2024-03-12 16:27:34 +01:00
for (const [name, field] of Object.entries(table.schema)) {
if (!this._isJsonColumn(field)) {
continue
}
2024-04-16 12:38:00 +02:00
const aliasedTableName = (tableName && aliases?.[tableName]) || tableName
const fullName = `${aliasedTableName}.${name}`
2024-03-12 16:27:34 +01:00
for (let row of results) {
if (typeof row[fullName as keyof T] === "string") {
row[fullName as keyof T] = JSON.parse(row[fullName])
2024-03-12 16:27:34 +01:00
}
if (typeof row[name as keyof T] === "string") {
row[name as keyof T] = JSON.parse(row[name])
2024-03-12 16:27:34 +01:00
}
}
}
return results
}
_isJsonColumn(
field: FieldSchema
): field is JsonFieldMetadata | BBReferenceFieldMetadata {
2024-03-12 16:27:34 +01:00
return (
JsonTypes.includes(field.type) &&
!helpers.schema.isDeprecatedSingleUserColumn(field)
2024-03-12 16:27:34 +01:00
)
}
log(query: string, values?: SqlQueryBinding) {
sqlLog(this.getSqlClient(), query, values)
2024-01-18 19:13:11 +01:00
}
}
export default SqlQueryBuilder