Merge pull request #14878 from Budibase/allow-bigints-in-calculations

This commit is contained in:
Sam Rose 2024-10-28 10:14:16 +00:00 committed by GitHub
commit c2108ac60d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 164 additions and 79 deletions

View File

@ -179,12 +179,6 @@ class InternalBuilder {
return this.table.schema[column] return this.table.schema[column]
} }
private supportsILike(): boolean {
return !(
this.client === SqlClient.ORACLE || this.client === SqlClient.SQL_LITE
)
}
private quoteChars(): [string, string] { private quoteChars(): [string, string] {
const wrapped = this.knexClient.wrapIdentifier("foo", {}) const wrapped = this.knexClient.wrapIdentifier("foo", {})
return [wrapped[0], wrapped[wrapped.length - 1]] return [wrapped[0], wrapped[wrapped.length - 1]]
@ -216,8 +210,30 @@ class InternalBuilder {
return formatter.wrap(value, false) return formatter.wrap(value, false)
} }
private rawQuotedValue(value: string): Knex.Raw { private castIntToString(identifier: string | Knex.Raw): Knex.Raw {
return this.knex.raw(this.quotedValue(value)) switch (this.client) {
case SqlClient.ORACLE: {
return this.knex.raw("to_char(??)", [identifier])
}
case SqlClient.POSTGRES: {
return this.knex.raw("??::TEXT", [identifier])
}
case SqlClient.MY_SQL:
case SqlClient.MARIADB: {
return this.knex.raw("CAST(?? AS CHAR)", [identifier])
}
case SqlClient.SQL_LITE: {
// Technically sqlite can actually represent numbers larger than a 64bit
// int as a string, but it does it using scientific notation (e.g.
// "1e+20") which is not what we want. Given that the external SQL
// databases are limited to supporting only 64bit ints, we settle for
// that here.
return this.knex.raw("printf('%d', ??)", [identifier])
}
case SqlClient.MS_SQL: {
return this.knex.raw("CONVERT(NVARCHAR, ??)", [identifier])
}
}
} }
// Unfortuantely we cannot rely on knex's identifier escaping because it trims // Unfortuantely we cannot rely on knex's identifier escaping because it trims
@ -1078,21 +1094,26 @@ class InternalBuilder {
query = query.count(`* as ${aggregation.name}`) query = query.count(`* as ${aggregation.name}`)
} }
} else { } else {
const field = `${tableName}.${aggregation.field} as ${aggregation.name}` const fieldSchema = this.getFieldSchema(aggregation.field)
switch (op) { if (!fieldSchema) {
case CalculationType.SUM: // This should not happen in practice.
query = query.sum(field) throw new Error(
break `field schema missing for aggregation target: ${aggregation.field}`
case CalculationType.AVG: )
query = query.avg(field)
break
case CalculationType.MIN:
query = query.min(field)
break
case CalculationType.MAX:
query = query.max(field)
break
} }
let aggregate = this.knex.raw("??(??)", [
this.knex.raw(op),
this.rawQuotedIdentifier(`${tableName}.${aggregation.field}`),
])
if (fieldSchema.type === FieldType.BIGINT) {
aggregate = this.castIntToString(aggregate)
}
query = query.select(
this.knex.raw("?? as ??", [aggregate, aggregation.name])
)
} }
} }
return query return query

View File

@ -7,7 +7,7 @@
Icon, Icon,
Multiselect, Multiselect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { CalculationType, canGroupBy, FieldType } from "@budibase/types" import { CalculationType, canGroupBy, isNumeric } from "@budibase/types"
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte" import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
@ -90,10 +90,7 @@
return Object.entries(schema) return Object.entries(schema)
.filter(([field, fieldSchema]) => { .filter(([field, fieldSchema]) => {
// Only allow numeric fields that are not calculations themselves // Only allow numeric fields that are not calculations themselves
if ( if (fieldSchema.calculationType || !isNumeric(fieldSchema.type)) {
fieldSchema.calculationType ||
fieldSchema.type !== FieldType.NUMBER
) {
return false return false
} }
// Don't allow duplicates // Don't allow duplicates

View File

@ -2268,58 +2268,118 @@ describe.each([
}) })
}) })
describe("calculation views", () => { !isLucene &&
it("should not remove calculation columns when modifying table schema", async () => { describe("calculation views", () => {
let table = await config.api.table.save( it("should not remove calculation columns when modifying table schema", async () => {
saveTableRequest({ let table = await config.api.table.save(
schema: { saveTableRequest({
name: { schema: {
name: "name", name: {
type: FieldType.STRING, name: "name",
type: FieldType.STRING,
},
age: {
name: "age",
type: FieldType.NUMBER,
},
}, },
age: { })
name: "age", )
type: FieldType.NUMBER,
let view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "age",
}, },
}, },
}) })
)
let view = await config.api.viewV2.create({ table = await config.api.table.get(table._id!)
tableId: table._id!, await config.api.table.save({
name: generator.guid(), ...table,
type: ViewV2Type.CALCULATION, schema: {
schema: { ...table.schema,
sum: { name: {
visible: true, name: "name",
calculationType: CalculationType.SUM, type: FieldType.STRING,
field: "age", constraints: { presence: true },
},
}, },
}, })
view = await config.api.viewV2.get(view.id)
expect(Object.keys(view.schema!).sort()).toEqual([
"age",
"id",
"name",
"sum",
])
}) })
table = await config.api.table.get(table._id!) describe("bigints", () => {
await config.api.table.save({ let table: Table
...table, let view: ViewV2
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
constraints: { presence: true },
},
},
})
view = await config.api.viewV2.get(view.id) beforeEach(async () => {
expect(Object.keys(view.schema!).sort()).toEqual([ table = await config.api.table.save(
"age", saveTableRequest({
"id", schema: {
"name", bigint: {
"sum", name: "bigint",
]) type: FieldType.BIGINT,
},
},
})
)
view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
sum: {
visible: true,
calculationType: CalculationType.SUM,
field: "bigint",
},
},
})
})
it("should not lose precision handling ints larger than JSs int53", async () => {
// The sum of the following 3 numbers cannot be represented by
// JavaScripts default int53 datatype for numbers, so this is a test
// that makes sure we aren't losing precision between the DB and the
// user.
await config.api.row.bulkImport(table._id!, {
rows: [
{ bigint: "1000000000000000000" },
{ bigint: "123" },
{ bigint: "321" },
],
})
const { rows } = await config.api.row.search(view.id)
expect(rows).toHaveLength(1)
expect(rows[0].sum).toEqual("1000000000000000444")
})
it("should be able to handle up to 2**63 - 1 bigints", async () => {
await config.api.row.bulkImport(table._id!, {
rows: [{ bigint: "9223372036854775806" }, { bigint: "1" }],
})
const { rows } = await config.api.row.search(view.id)
expect(rows).toHaveLength(1)
expect(rows[0].sum).toEqual("9223372036854775807")
})
})
}) })
})
}) })
describe("row operations", () => { describe("row operations", () => {

View File

@ -440,19 +440,26 @@ export async function coreOutputProcessing(
} }
if (sdk.views.isView(source)) { if (sdk.views.isView(source)) {
const calculationFields = Object.keys( // We ensure calculation fields are returned as numbers. During the
helpers.views.calculationFields(source)
)
// We ensure all calculation fields are returned as numbers. During the
// testing of this feature it was discovered that the COUNT operation // testing of this feature it was discovered that the COUNT operation
// returns a string for MySQL, MariaDB, and Postgres. But given that all // returns a string for MySQL, MariaDB, and Postgres. But given that all
// calculation fields should be numbers, we blanket make sure of that // calculation fields (except ones operating on BIGINTs) should be
// here. // numbers, we blanket make sure of that here.
for (const key of calculationFields) { for (const [name, field] of Object.entries(
helpers.views.calculationFields(source)
)) {
if ("field" in field) {
const targetSchema = table.schema[field.field]
// We don't convert BIGINT fields to floats because we could lose
// precision.
if (targetSchema.type === FieldType.BIGINT) {
continue
}
}
for (const row of rows) { for (const row of rows) {
if (typeof row[key] === "string") { if (typeof row[name] === "string") {
row[key] = parseFloat(row[key]) row[name] = parseFloat(row[name])
} }
} }
} }