Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MariaDB - avoid using correlated sub-queries #14630

Merged
merged 11 commits into from
Sep 24, 2024
74 changes: 43 additions & 31 deletions packages/backend-core/src/sql/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ class InternalBuilder {
return `"${str}"`
case SqlClient.MS_SQL:
return `[${str}]`
case SqlClient.MARIADB:
case SqlClient.MY_SQL:
return `\`${str}\``
}
Expand Down Expand Up @@ -559,7 +560,10 @@ class InternalBuilder {
)}${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"
iterate(mode, (q, key, value) => {
return q[rawFnc](
Expand Down Expand Up @@ -930,7 +934,8 @@ class InternalBuilder {
}
const relatedTable = meta.tables?.[toTable]
const toAlias = aliases?.[toTable] || toTable,
fromAlias = aliases?.[fromTable] || fromTable
fromAlias = aliases?.[fromTable] || fromTable,
throughAlias = (throughTable && aliases?.[throughTable]) || throughTable
let toTableWithSchema = this.tableNameWithSchema(toTable, {
alias: toAlias,
schema: endpoint.schema,
Expand All @@ -957,38 +962,36 @@ class InternalBuilder {
const primaryKey = `${toAlias}.${toPrimary || toKey}`
let subQuery: Knex.QueryBuilder = knex
.from(toTableWithSchema)
.limit(getRelationshipLimit())
// add sorting to get consistent order
.orderBy(primaryKey)

// many-to-many relationship with junction table
if (throughTable && toPrimary && fromPrimary) {
const throughAlias = aliases?.[throughTable] || throughTable
const isManyToMany = throughTable && toPrimary && fromPrimary
let correlatedTo = isManyToMany
? `${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, {
alias: throughAlias,
schema: endpoint.schema,
})
subQuery = subQuery
.join(throughTableWithSchema, function () {
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}`))
)
subQuery = subQuery.join(throughTableWithSchema, function () {
this.on(`${toAlias}.${toPrimary}`, "=", `${throughAlias}.${toKey}`)
})
}

// add the correlation to the overall query
subQuery = subQuery.where(
correlatedTo,
"=",
knex.raw(this.quotedIdentifier(correlatedFrom))
)

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
return knex.select(knex.raw(select)).from({
[toAlias]: subQuery,
Expand All @@ -1008,11 +1011,15 @@ class InternalBuilder {
`json_agg(json_build_object(${fieldList}))`
)
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(
knex.raw(`json_arrayagg(json_object(${fieldList}))`)
knex.raw(
`json_arrayagg(json_object(${fieldList}) LIMIT ${getRelationshipLimit()})`
)
)
break
case SqlClient.MY_SQL:
case SqlClient.ORACLE:
wrapperQuery = standardWrap(
`json_arrayagg(json_object(${fieldList}))`
Expand All @@ -1024,7 +1031,9 @@ class InternalBuilder {
.select(`${fromAlias}.*`)
// @ts-ignore - from alias syntax not TS supported
.from({
[fromAlias]: subQuery.select(`${toAlias}.*`),
[fromAlias]: subQuery
.select(`${toAlias}.*`)
.limit(getRelationshipLimit()),
})} FOR JSON PATH))`
)
break
Expand Down Expand Up @@ -1179,7 +1188,8 @@ class InternalBuilder {
if (
this.client === SqlClient.POSTGRES ||
this.client === SqlClient.SQL_LITE ||
this.client === SqlClient.MY_SQL
this.client === SqlClient.MY_SQL ||
this.client === SqlClient.MARIADB
) {
const primary = this.table.primary
if (!primary) {
Expand Down Expand Up @@ -1326,12 +1336,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
_query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] {
const sqlClient = this.getSqlClient()
const config: Knex.Config = {
client: sqlClient,
client: this.getBaseSqlClient(),
}
if (sqlClient === SqlClient.SQL_LITE || sqlClient === SqlClient.ORACLE) {
config.useNullAsDefault = true
}

const client = knex(config)
let query: Knex.QueryBuilder
const builder = new InternalBuilder(sqlClient, client, json)
Expand Down Expand Up @@ -1440,7 +1449,10 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
let id
if (sqlClient === SqlClient.MS_SQL) {
id = results?.[0].id
} else if (sqlClient === SqlClient.MY_SQL) {
} else if (
sqlClient === SqlClient.MY_SQL ||
sqlClient === SqlClient.MARIADB
) {
id = results?.insertId
}
row = processFn(
Expand Down
13 changes: 12 additions & 1 deletion packages/backend-core/src/sql/sqlTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,16 +210,27 @@ function buildDeleteTable(knex: SchemaBuilder, table: Table): SchemaBuilder {

class SqlTableQueryBuilder {
private readonly sqlClient: SqlClient
private extendedSqlClient: SqlClient | undefined
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh how come this is necessary? Why can't we just use sqlClient?

Copy link
Collaborator Author

@mike12345567 mike12345567 Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to know that the implementation/dialect is MY_SQL - but also its MariaDB - for Knex it needs to know its MySQL, technically we could get around this by setting everywhere that needs to know its MySQL based on it being MariaDB when needed, but I like the idea of separating the difference in client here, since its still a MySQL datasource, it just reads a different version once it connects.


// pass through client to get flavour of SQL
constructor(client: SqlClient) {
this.sqlClient = client
}

getSqlClient(): SqlClient {
getBaseSqlClient(): 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.
* @return the operation that was found in the JSON.
Expand Down
104 changes: 76 additions & 28 deletions packages/server/src/api/routes/tests/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ import tk from "timekeeper"
import { encodeJSBinding } from "@budibase/string-templates"
import { dataFilters } from "@budibase/shared-core"
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 { generateRowIdField } from "../../../integrations/utils"
import { cloneDeep } from "lodash/fp"

describe.each([
["in-memory", undefined],
Expand All @@ -66,6 +67,36 @@ describe.each([
let table: Table
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 () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init())
if (isLucene) {
Expand Down Expand Up @@ -201,6 +232,7 @@ describe.each([
// rows returned by the query will also cause the assertion to fail.
async toMatchExactly(expectedRows: any[]) {
const response = await this.performSearch()
const cloned = cloneDeep(response)
const foundRows = response.rows

// eslint-disable-next-line jest/no-standalone-expect
Expand All @@ -211,14 +243,15 @@ describe.each([
expect.objectContaining(this.popRow(expectedRow, foundRows))
)
)
return response
return cloned
}

// Asserts that the query returns rows matching exactly the set of rows
// passed in. The order of the rows is not important, but extra rows will
// cause the assertion to fail.
async toContainExactly(expectedRows: any[]) {
const response = await this.performSearch()
const cloned = cloneDeep(response)
const foundRows = response.rows

// eslint-disable-next-line jest/no-standalone-expect
Expand All @@ -231,14 +264,15 @@ describe.each([
)
)
)
return response
return cloned
}

// Asserts that the query returns some property values - this cannot be used
// to check row values, however this shouldn't be important for checking properties
// typing for this has to be any, Jest doesn't expose types for matchers like expect.any(...)
async toMatch(properties: Record<string, any>) {
const response = await this.performSearch()
const cloned = cloneDeep(response)
const keys = Object.keys(properties) as Array<keyof SearchResponse<Row>>
for (let key of keys) {
// eslint-disable-next-line jest/no-standalone-expect
Expand All @@ -248,24 +282,26 @@ describe.each([
expect(response[key]).toEqual(properties[key])
}
}
return response
return cloned
}

// Asserts that the query doesn't return a property, e.g. pagination parameters.
async toNotHaveProperty(properties: (keyof SearchResponse<Row>)[]) {
const response = await this.performSearch()
const cloned = cloneDeep(response)
for (let property of properties) {
// eslint-disable-next-line jest/no-standalone-expect
expect(response[property]).toBeUndefined()
}
return response
return cloned
}

// Asserts that the query returns rows matching the set of rows passed in.
// The order of the rows is not important. Extra rows will not cause the
// assertion to fail.
async toContain(expectedRows: any[]) {
const response = await this.performSearch()
const cloned = cloneDeep(response)
const foundRows = response.rows

// eslint-disable-next-line jest/no-standalone-expect
Expand All @@ -276,7 +312,7 @@ describe.each([
)
)
)
return response
return cloned
}

async toFindNothing() {
Expand Down Expand Up @@ -2196,28 +2232,10 @@ describe.each([
let productCategoryTable: Table, productCatRows: Row[]

beforeAll(async () => {
productCategoryTable = await createTable(
{
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"
const { relatedTable } = await basicRelationshipTables(
RelationshipType.ONE_TO_MANY
)
productCategoryTable = relatedTable

productCatRows = await Promise.all([
config.api.row.save(productCategoryTable._id!, { name: "foo" }),
Expand Down Expand Up @@ -2250,7 +2268,7 @@ describe.each([

it("should be able to filter by relationship using table name", async () => {
await expectQuery({
equal: { ["productCategory.name"]: "foo" },
equal: { [`${productCategoryTable.name}.name`]: "foo" },
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
])
Expand All @@ -2262,6 +2280,36 @@ describe.each([
}).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)
mike12345567 marked this conversation as resolved.
Show resolved Hide resolved
})
})

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) &&
describe("relations to same table", () => {
let relatedTable: Table, relatedRows: Row[]
Expand Down
Loading
Loading