Merge pull request #14630 from Budibase/fix/mysql-correlated-queries

MariaDB - avoid using correlated sub-queries
This commit is contained in:
Michael Drury 2024-09-24 14:15:46 +01:00 committed by GitHub
commit d555b684bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 170 additions and 72 deletions

View File

@ -150,6 +150,7 @@ class InternalBuilder {
return `"${str}"` return `"${str}"`
case SqlClient.MS_SQL: case SqlClient.MS_SQL:
return `[${str}]` return `[${str}]`
case SqlClient.MARIADB:
case SqlClient.MY_SQL: case SqlClient.MY_SQL:
return `\`${str}\`` return `\`${str}\``
} }
@ -559,7 +560,10 @@ class InternalBuilder {
)}${wrap}, FALSE)` )}${wrap}, FALSE)`
) )
}) })
} else if (this.client === SqlClient.MY_SQL) { } else if (
this.client === SqlClient.MY_SQL ||
this.client === SqlClient.MARIADB
) {
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS" const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
iterate(mode, (q, key, value) => { iterate(mode, (q, key, value) => {
return q[rawFnc]( return q[rawFnc](
@ -930,7 +934,8 @@ class InternalBuilder {
} }
const relatedTable = meta.tables?.[toTable] const relatedTable = meta.tables?.[toTable]
const toAlias = aliases?.[toTable] || toTable, const toAlias = aliases?.[toTable] || toTable,
fromAlias = aliases?.[fromTable] || fromTable fromAlias = aliases?.[fromTable] || fromTable,
throughAlias = (throughTable && aliases?.[throughTable]) || throughTable
let toTableWithSchema = this.tableNameWithSchema(toTable, { let toTableWithSchema = this.tableNameWithSchema(toTable, {
alias: toAlias, alias: toAlias,
schema: endpoint.schema, schema: endpoint.schema,
@ -957,38 +962,36 @@ class InternalBuilder {
const primaryKey = `${toAlias}.${toPrimary || toKey}` const primaryKey = `${toAlias}.${toPrimary || toKey}`
let subQuery: Knex.QueryBuilder = knex let subQuery: Knex.QueryBuilder = knex
.from(toTableWithSchema) .from(toTableWithSchema)
.limit(getRelationshipLimit())
// add sorting to get consistent order // add sorting to get consistent order
.orderBy(primaryKey) .orderBy(primaryKey)
// many-to-many relationship with junction table const isManyToMany = throughTable && toPrimary && fromPrimary
if (throughTable && toPrimary && fromPrimary) { let correlatedTo = isManyToMany
const throughAlias = aliases?.[throughTable] || throughTable ? `${throughAlias}.${fromKey}`
: `${toAlias}.${toKey}`,
correlatedFrom = isManyToMany
? `${fromAlias}.${fromPrimary}`
: `${fromAlias}.${fromKey}`
// many-to-many relationship needs junction table join
if (isManyToMany) {
let throughTableWithSchema = this.tableNameWithSchema(throughTable, { let throughTableWithSchema = this.tableNameWithSchema(throughTable, {
alias: throughAlias, alias: throughAlias,
schema: endpoint.schema, schema: endpoint.schema,
}) })
subQuery = subQuery subQuery = subQuery.join(throughTableWithSchema, function () {
.join(throughTableWithSchema, function () { this.on(`${toAlias}.${toPrimary}`, "=", `${throughAlias}.${toKey}`)
this.on(`${toAlias}.${toPrimary}`, "=", `${throughAlias}.${toKey}`) })
})
.where(
`${throughAlias}.${fromKey}`,
"=",
knex.raw(this.quotedIdentifier(`${fromAlias}.${fromPrimary}`))
)
}
// one-to-many relationship with foreign key
else {
subQuery = subQuery.where(
`${toAlias}.${toKey}`,
"=",
knex.raw(this.quotedIdentifier(`${fromAlias}.${fromKey}`))
)
} }
// add the correlation to the overall query
subQuery = subQuery.where(
correlatedTo,
"=",
knex.raw(this.quotedIdentifier(correlatedFrom))
)
const standardWrap = (select: string): Knex.QueryBuilder => { const standardWrap = (select: string): Knex.QueryBuilder => {
subQuery = subQuery.select(`${toAlias}.*`) subQuery = subQuery.select(`${toAlias}.*`).limit(getRelationshipLimit())
// @ts-ignore - the from alias syntax isn't in Knex typing // @ts-ignore - the from alias syntax isn't in Knex typing
return knex.select(knex.raw(select)).from({ return knex.select(knex.raw(select)).from({
[toAlias]: subQuery, [toAlias]: subQuery,
@ -1008,11 +1011,15 @@ class InternalBuilder {
`json_agg(json_build_object(${fieldList}))` `json_agg(json_build_object(${fieldList}))`
) )
break break
case SqlClient.MY_SQL: case SqlClient.MARIADB:
// can't use the standard wrap due to correlated sub-query limitations in MariaDB
wrapperQuery = subQuery.select( wrapperQuery = subQuery.select(
knex.raw(`json_arrayagg(json_object(${fieldList}))`) knex.raw(
`json_arrayagg(json_object(${fieldList}) LIMIT ${getRelationshipLimit()})`
)
) )
break break
case SqlClient.MY_SQL:
case SqlClient.ORACLE: case SqlClient.ORACLE:
wrapperQuery = standardWrap( wrapperQuery = standardWrap(
`json_arrayagg(json_object(${fieldList}))` `json_arrayagg(json_object(${fieldList}))`
@ -1024,7 +1031,9 @@ class InternalBuilder {
.select(`${fromAlias}.*`) .select(`${fromAlias}.*`)
// @ts-ignore - from alias syntax not TS supported // @ts-ignore - from alias syntax not TS supported
.from({ .from({
[fromAlias]: subQuery.select(`${toAlias}.*`), [fromAlias]: subQuery
.select(`${toAlias}.*`)
.limit(getRelationshipLimit()),
})} FOR JSON PATH))` })} FOR JSON PATH))`
) )
break break
@ -1179,7 +1188,8 @@ class InternalBuilder {
if ( if (
this.client === SqlClient.POSTGRES || this.client === SqlClient.POSTGRES ||
this.client === SqlClient.SQL_LITE || this.client === SqlClient.SQL_LITE ||
this.client === SqlClient.MY_SQL this.client === SqlClient.MY_SQL ||
this.client === SqlClient.MARIADB
) { ) {
const primary = this.table.primary const primary = this.table.primary
if (!primary) { if (!primary) {
@ -1326,12 +1336,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
_query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] { _query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] {
const sqlClient = this.getSqlClient() const sqlClient = this.getSqlClient()
const config: Knex.Config = { const config: Knex.Config = {
client: sqlClient, client: this.getBaseSqlClient(),
} }
if (sqlClient === SqlClient.SQL_LITE || sqlClient === SqlClient.ORACLE) { if (sqlClient === SqlClient.SQL_LITE || sqlClient === SqlClient.ORACLE) {
config.useNullAsDefault = true config.useNullAsDefault = true
} }
const client = knex(config) const client = knex(config)
let query: Knex.QueryBuilder let query: Knex.QueryBuilder
const builder = new InternalBuilder(sqlClient, client, json) const builder = new InternalBuilder(sqlClient, client, json)
@ -1440,7 +1449,10 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
let id let id
if (sqlClient === SqlClient.MS_SQL) { if (sqlClient === SqlClient.MS_SQL) {
id = results?.[0].id id = results?.[0].id
} else if (sqlClient === SqlClient.MY_SQL) { } else if (
sqlClient === SqlClient.MY_SQL ||
sqlClient === SqlClient.MARIADB
) {
id = results?.insertId id = results?.insertId
} }
row = processFn( row = processFn(

View File

@ -210,16 +210,27 @@ function buildDeleteTable(knex: SchemaBuilder, table: Table): SchemaBuilder {
class SqlTableQueryBuilder { class SqlTableQueryBuilder {
private readonly sqlClient: SqlClient private readonly sqlClient: SqlClient
private extendedSqlClient: SqlClient | undefined
// pass through client to get flavour of SQL // pass through client to get flavour of SQL
constructor(client: SqlClient) { constructor(client: SqlClient) {
this.sqlClient = client this.sqlClient = client
} }
getSqlClient(): SqlClient { getBaseSqlClient(): SqlClient {
return this.sqlClient return this.sqlClient
} }
getSqlClient(): SqlClient {
return this.extendedSqlClient || this.sqlClient
}
// if working in a database like MySQL with many variants (MariaDB)
// we can set another client which overrides the base one
setExtendedSqlClient(client: SqlClient) {
this.extendedSqlClient = client
}
/** /**
* @param json the input JSON structure from which an SQL query will be built. * @param json the input JSON structure from which an SQL query will be built.
* @return the operation that was found in the JSON. * @return the operation that was found in the JSON.

View File

@ -39,9 +39,10 @@ import tk from "timekeeper"
import { encodeJSBinding } from "@budibase/string-templates" import { encodeJSBinding } from "@budibase/string-templates"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import { Knex } from "knex" import { Knex } from "knex"
import { structures } from "@budibase/backend-core/tests" import { generator, structures } from "@budibase/backend-core/tests"
import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default" import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default"
import { generateRowIdField } from "../../../integrations/utils" import { generateRowIdField } from "../../../integrations/utils"
import { cloneDeep } from "lodash/fp"
describe.each([ describe.each([
["in-memory", undefined], ["in-memory", undefined],
@ -66,6 +67,36 @@ describe.each([
let table: Table let table: Table
let rows: Row[] let rows: Row[]
async function basicRelationshipTables(type: RelationshipType) {
const relatedTable = await createTable(
{
name: { name: "name", type: FieldType.STRING },
},
generator.guid().substring(0, 10)
)
table = await createTable(
{
name: { name: "name", type: FieldType.STRING },
//@ts-ignore - API accepts this structure, will build out rest of definition
productCat: {
type: FieldType.LINK,
relationshipType: type,
name: "productCat",
fieldName: "product",
tableId: relatedTable._id!,
constraints: {
type: "array",
},
},
},
generator.guid().substring(0, 10)
)
return {
relatedTable: await config.api.table.get(relatedTable._id!),
table,
}
}
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init()) await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init())
if (isLucene) { if (isLucene) {
@ -201,6 +232,7 @@ describe.each([
// rows returned by the query will also cause the assertion to fail. // rows returned by the query will also cause the assertion to fail.
async toMatchExactly(expectedRows: any[]) { async toMatchExactly(expectedRows: any[]) {
const response = await this.performSearch() const response = await this.performSearch()
const cloned = cloneDeep(response)
const foundRows = response.rows const foundRows = response.rows
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
@ -211,7 +243,7 @@ describe.each([
expect.objectContaining(this.popRow(expectedRow, foundRows)) expect.objectContaining(this.popRow(expectedRow, foundRows))
) )
) )
return response return cloned
} }
// Asserts that the query returns rows matching exactly the set of rows // Asserts that the query returns rows matching exactly the set of rows
@ -219,6 +251,7 @@ describe.each([
// cause the assertion to fail. // cause the assertion to fail.
async toContainExactly(expectedRows: any[]) { async toContainExactly(expectedRows: any[]) {
const response = await this.performSearch() const response = await this.performSearch()
const cloned = cloneDeep(response)
const foundRows = response.rows const foundRows = response.rows
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
@ -231,7 +264,7 @@ describe.each([
) )
) )
) )
return response return cloned
} }
// Asserts that the query returns some property values - this cannot be used // Asserts that the query returns some property values - this cannot be used
@ -239,6 +272,7 @@ describe.each([
// typing for this has to be any, Jest doesn't expose types for matchers like expect.any(...) // typing for this has to be any, Jest doesn't expose types for matchers like expect.any(...)
async toMatch(properties: Record<string, any>) { async toMatch(properties: Record<string, any>) {
const response = await this.performSearch() const response = await this.performSearch()
const cloned = cloneDeep(response)
const keys = Object.keys(properties) as Array<keyof SearchResponse<Row>> const keys = Object.keys(properties) as Array<keyof SearchResponse<Row>>
for (let key of keys) { for (let key of keys) {
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
@ -248,17 +282,18 @@ describe.each([
expect(response[key]).toEqual(properties[key]) expect(response[key]).toEqual(properties[key])
} }
} }
return response return cloned
} }
// Asserts that the query doesn't return a property, e.g. pagination parameters. // Asserts that the query doesn't return a property, e.g. pagination parameters.
async toNotHaveProperty(properties: (keyof SearchResponse<Row>)[]) { async toNotHaveProperty(properties: (keyof SearchResponse<Row>)[]) {
const response = await this.performSearch() const response = await this.performSearch()
const cloned = cloneDeep(response)
for (let property of properties) { for (let property of properties) {
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
expect(response[property]).toBeUndefined() expect(response[property]).toBeUndefined()
} }
return response return cloned
} }
// Asserts that the query returns rows matching the set of rows passed in. // Asserts that the query returns rows matching the set of rows passed in.
@ -266,6 +301,7 @@ describe.each([
// assertion to fail. // assertion to fail.
async toContain(expectedRows: any[]) { async toContain(expectedRows: any[]) {
const response = await this.performSearch() const response = await this.performSearch()
const cloned = cloneDeep(response)
const foundRows = response.rows const foundRows = response.rows
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
@ -276,7 +312,7 @@ describe.each([
) )
) )
) )
return response return cloned
} }
async toFindNothing() { async toFindNothing() {
@ -2196,28 +2232,10 @@ describe.each([
let productCategoryTable: Table, productCatRows: Row[] let productCategoryTable: Table, productCatRows: Row[]
beforeAll(async () => { beforeAll(async () => {
productCategoryTable = await createTable( const { relatedTable } = await basicRelationshipTables(
{ RelationshipType.ONE_TO_MANY
name: { name: "name", type: FieldType.STRING },
},
"productCategory"
)
table = await createTable(
{
name: { name: "name", type: FieldType.STRING },
productCat: {
type: FieldType.LINK,
relationshipType: RelationshipType.ONE_TO_MANY,
name: "productCat",
fieldName: "product",
tableId: productCategoryTable._id!,
constraints: {
type: "array",
},
},
},
"product"
) )
productCategoryTable = relatedTable
productCatRows = await Promise.all([ productCatRows = await Promise.all([
config.api.row.save(productCategoryTable._id!, { name: "foo" }), config.api.row.save(productCategoryTable._id!, { name: "foo" }),
@ -2250,7 +2268,7 @@ describe.each([
it("should be able to filter by relationship using table name", async () => { it("should be able to filter by relationship using table name", async () => {
await expectQuery({ await expectQuery({
equal: { ["productCategory.name"]: "foo" }, equal: { [`${productCategoryTable.name}.name`]: "foo" },
}).toContainExactly([ }).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] }, { name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
]) ])
@ -2262,6 +2280,36 @@ describe.each([
}).toContainExactly([{ name: "baz", productCat: undefined }]) }).toContainExactly([{ name: "baz", productCat: undefined }])
}) })
}) })
isSql &&
describe("big relations", () => {
beforeAll(async () => {
const { relatedTable } = await basicRelationshipTables(
RelationshipType.MANY_TO_ONE
)
const mainRow = await config.api.row.save(table._id!, {
name: "foo",
})
for (let i = 0; i < 11; i++) {
await config.api.row.save(relatedTable._id!, {
name: i,
product: [mainRow._id!],
})
}
})
it("can only pull 10 related rows", async () => {
await withCoreEnv({ SQL_MAX_RELATED_ROWS: "10" }, async () => {
const response = await expectQuery({}).toContain([{ name: "foo" }])
expect(response.rows[0].productCat).toBeArrayOfSize(10)
})
})
it("can pull max rows when env not set (defaults to 500)", async () => {
const response = await expectQuery({}).toContain([{ name: "foo" }])
expect(response.rows[0].productCat).toBeArrayOfSize(11)
})
})
;(isSqs || isLucene) && ;(isSqs || isLucene) &&
describe("relations to same table", () => { describe("relations to same table", () => {
let relatedTable: Table, relatedRows: Row[] let relatedTable: Table, relatedRows: Row[]

View File

@ -241,6 +241,16 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
async connect() { async connect() {
this.client = await mysql.createConnection(this.config) this.client = await mysql.createConnection(this.config)
const res = await this.internalQuery(
{
sql: "SELECT VERSION();",
},
{ connect: false }
)
const version = res?.[0]?.["VERSION()"]
if (version?.toLowerCase().includes("mariadb")) {
this.setExtendedSqlClient(SqlClient.MARIADB)
}
} }
async disconnect() { async disconnect() {

View File

@ -198,12 +198,15 @@ export async function save(
} }
} }
generateRelatedSchema(schema, relatedTable, tableToSave, relatedColumnName) generateRelatedSchema(schema, relatedTable, tableToSave, relatedColumnName)
tables[relatedTable.name] = relatedTable
schema.main = true schema.main = true
} }
// add in the new table for relationship purposes // add in the new table for relationship purposes
tables[tableToSave.name] = tableToSave tables[tableToSave.name] = tableToSave
cleanupRelationships(tableToSave, tables, oldTable) if (oldTable) {
cleanupRelationships(tableToSave, tables, { oldTable })
}
const operation = tableId ? Operation.UPDATE_TABLE : Operation.CREATE_TABLE const operation = tableId ? Operation.UPDATE_TABLE : Operation.CREATE_TABLE
await makeTableRequest( await makeTableRequest(
@ -231,7 +234,10 @@ export async function save(
// remove the rename prop // remove the rename prop
delete tableToSave._rename delete tableToSave._rename
datasource.entities[tableToSave.name] = tableToSave datasource.entities = {
...datasource.entities,
...tables,
}
// store it into couch now for budibase reference // store it into couch now for budibase reference
await db.put(populateExternalTableSchemas(datasource)) await db.put(populateExternalTableSchemas(datasource))
@ -255,7 +261,7 @@ export async function destroy(datasourceId: string, table: Table) {
const operation = Operation.DELETE_TABLE const operation = Operation.DELETE_TABLE
if (tables) { if (tables) {
await makeTableRequest(datasource, operation, table, tables) await makeTableRequest(datasource, operation, table, tables)
cleanupRelationships(table, tables) cleanupRelationships(table, tables, { deleting: true })
delete tables[table.name] delete tables[table.name]
datasource.entities = tables datasource.entities = tables
} }

View File

@ -20,14 +20,26 @@ import { cloneDeep } from "lodash/fp"
export function cleanupRelationships( export function cleanupRelationships(
table: Table, table: Table,
tables: Record<string, Table>, tables: Record<string, Table>,
oldTable?: Table opts: { oldTable: Table }
) { ): void
export function cleanupRelationships(
table: Table,
tables: Record<string, Table>,
opts: { deleting: boolean }
): void
export function cleanupRelationships(
table: Table,
tables: Record<string, Table>,
opts?: { oldTable?: Table; deleting?: boolean }
): void {
const oldTable = opts?.oldTable
const tableToIterate = oldTable ? oldTable : table const tableToIterate = oldTable ? oldTable : table
// clean up relationships in couch table schemas // clean up relationships in couch table schemas
for (let [key, schema] of Object.entries(tableToIterate.schema)) { for (let [key, schema] of Object.entries(tableToIterate.schema)) {
if ( if (
schema.type === FieldType.LINK && schema.type === FieldType.LINK &&
(!oldTable || table.schema[key] == null) (opts?.deleting || oldTable?.schema[key] != null) &&
table.schema[key] == null
) { ) {
const schemaTableId = schema.tableId const schemaTableId = schema.tableId
const relatedTable = Object.values(tables).find( const relatedTable = Object.values(tables).find(

View File

@ -600,10 +600,10 @@ export function fullSchemaWithoutLinks({
allRequired, allRequired,
}: { }: {
allRequired?: boolean allRequired?: boolean
}) { }): {
const schema: { [type in Exclude<FieldType, FieldType.LINK>]: FieldSchema & { type: type }
[type in Exclude<FieldType, FieldType.LINK>]: FieldSchema & { type: type } } {
} = { return {
[FieldType.STRING]: { [FieldType.STRING]: {
name: "string", name: "string",
type: FieldType.STRING, type: FieldType.STRING,
@ -741,8 +741,6 @@ export function fullSchemaWithoutLinks({
}, },
}, },
} }
return schema
} }
export function basicAttachment() { export function basicAttachment() {
return { return {

View File

@ -195,6 +195,7 @@ export enum SqlClient {
MS_SQL = "mssql", MS_SQL = "mssql",
POSTGRES = "pg", POSTGRES = "pg",
MY_SQL = "mysql2", MY_SQL = "mysql2",
MARIADB = "mariadb",
ORACLE = "oracledb", ORACLE = "oracledb",
SQL_LITE = "sqlite3", SQL_LITE = "sqlite3",
} }