diff --git a/.github/workflows/deploy-featurebranch.yml b/.github/workflows/deploy-featurebranch.yml
index a676fe75f0..463074e836 100644
--- a/.github/workflows/deploy-featurebranch.yml
+++ b/.github/workflows/deploy-featurebranch.yml
@@ -23,7 +23,7 @@ jobs:
PAYLOAD_BRANCH: ${{ github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
PAYLOAD_LICENSE_TYPE: "free"
- PAYLOAD_DEPLOY: "true"
+ PAYLOAD_DEPLOY: true
with:
repository: budibase/budibase-deploys
event: featurebranch-qa-deploy
diff --git a/packages/backend-core/src/sql/sql.ts b/packages/backend-core/src/sql/sql.ts
index 3585dacbed..ed8dc929d6 100644
--- a/packages/backend-core/src/sql/sql.ts
+++ b/packages/backend-core/src/sql/sql.ts
@@ -139,29 +139,61 @@ class InternalBuilder {
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 {
+ private quoteChars(): [string, string] {
switch (this.client) {
- case SqlClient.SQL_LITE:
case SqlClient.ORACLE:
case SqlClient.POSTGRES:
- return `"${str}"`
+ return ['"', '"']
case SqlClient.MS_SQL:
- return `[${str}]`
+ return ["[", "]"]
case SqlClient.MARIADB:
case SqlClient.MY_SQL:
- return `\`${str}\``
+ case SqlClient.SQL_LITE:
+ return ["`", "`"]
}
}
- // 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(".")
+ // Takes a string like foo and returns a quoted string like [foo] for SQL Server
+ // and "foo" for Postgres.
+ private quote(str: string): string {
+ const [start, end] = this.quoteChars()
+ return `${start}${str}${end}`
+ }
+
+ private isQuoted(key: string): boolean {
+ const [start, end] = this.quoteChars()
+ return key.startsWith(start) && key.endsWith(end)
+ }
+
+ // Takes a string like a.b.c or an array 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[]): string {
+ if (!Array.isArray(key)) {
+ key = this.splitIdentifier(key)
+ }
+ return key.map(part => this.quote(part)).join(".")
+ }
+
+ // Turns an identifier like a.b.c or `a`.`b`.`c` into ["a", "b", "c"]
+ private splitIdentifier(key: string): string[] {
+ const [start, end] = this.quoteChars()
+ if (this.isQuoted(key)) {
+ return key.slice(1, -1).split(`${end}.${start}`)
+ }
+ return key.split(".")
+ }
+
+ private qualifyIdentifier(key: string): string {
+ const tableName = this.getTableName()
+ const parts = this.splitIdentifier(key)
+ if (parts[0] !== tableName) {
+ parts.unshift(tableName)
+ }
+ if (this.isQuoted(key)) {
+ return this.quotedIdentifier(parts)
+ }
+ return parts.join(".")
}
private isFullSelectStatementRequired(): boolean {
@@ -231,8 +263,13 @@ class InternalBuilder {
// 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(".")
+ private convertClobs(field: string, opts?: { forSelect?: boolean }): string {
+ if (this.client !== SqlClient.ORACLE) {
+ throw new Error(
+ "you've called convertClobs on a DB that's not Oracle, this is a mistake"
+ )
+ }
+ const parts = this.splitIdentifier(field)
const col = parts.pop()!
const schema = this.table.schema[col]
let identifier = this.quotedIdentifier(field)
@@ -244,7 +281,11 @@ class InternalBuilder {
schema.type === FieldType.OPTIONS ||
schema.type === FieldType.BARCODEQR
) {
- identifier = `to_char(${identifier})`
+ if (opts?.forSelect) {
+ identifier = `to_char(${identifier}) as ${this.quotedIdentifier(col)}`
+ } else {
+ identifier = `to_char(${identifier})`
+ }
}
return identifier
}
@@ -859,28 +900,58 @@ class InternalBuilder {
const fields = this.query.resource?.fields || []
const tableName = this.getTableName()
if (fields.length > 0) {
- query = query.groupBy(fields.map(field => `${tableName}.${field}`))
- query = query.select(fields.map(field => `${tableName}.${field}`))
+ const qualifiedFields = fields.map(field => this.qualifyIdentifier(field))
+ if (this.client === SqlClient.ORACLE) {
+ const groupByFields = qualifiedFields.map(field =>
+ this.convertClobs(field)
+ )
+ const selectFields = qualifiedFields.map(field =>
+ this.convertClobs(field, { forSelect: true })
+ )
+ query = query
+ .groupByRaw(groupByFields.join(", "))
+ .select(this.knex.raw(selectFields.join(", ")))
+ } else {
+ query = query.groupBy(qualifiedFields).select(qualifiedFields)
+ }
}
for (const aggregation of aggregations) {
const op = aggregation.calculationType
- const field = `${tableName}.${aggregation.field} as ${aggregation.name}`
- switch (op) {
- case CalculationType.COUNT:
- query = query.count(field)
- break
- case CalculationType.SUM:
- query = query.sum(field)
- break
- case CalculationType.AVG:
- query = query.avg(field)
- break
- case CalculationType.MIN:
- query = query.min(field)
- break
- case CalculationType.MAX:
- query = query.max(field)
- break
+ if (op === CalculationType.COUNT) {
+ if ("distinct" in aggregation && aggregation.distinct) {
+ if (this.client === SqlClient.ORACLE) {
+ const field = this.convertClobs(`${tableName}.${aggregation.field}`)
+ query = query.select(
+ this.knex.raw(
+ `COUNT(DISTINCT ${field}) as ${this.quotedIdentifier(
+ aggregation.name
+ )}`
+ )
+ )
+ } else {
+ query = query.countDistinct(
+ `${tableName}.${aggregation.field} as ${aggregation.name}`
+ )
+ }
+ } else {
+ query = query.count(`* as ${aggregation.name}`)
+ }
+ } else {
+ const field = `${tableName}.${aggregation.field} as ${aggregation.name}`
+ switch (op) {
+ case CalculationType.SUM:
+ query = query.sum(field)
+ break
+ case CalculationType.AVG:
+ query = query.avg(field)
+ break
+ case CalculationType.MIN:
+ query = query.min(field)
+ break
+ case CalculationType.MAX:
+ query = query.max(field)
+ break
+ }
}
}
return query
diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte
index 3f0d6f11c5..f61e19c19d 100644
--- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte
+++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte
@@ -95,7 +95,7 @@
{#if isView}
toggleAction(action, e.detail)}
/>
diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte
index d8edf0cbb1..4186cb54cc 100644
--- a/packages/builder/src/components/common/bindings/BindingPanel.svelte
+++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte
@@ -66,6 +66,7 @@
let insertAtPos
let targetMode = null
let expressionResult
+ let expressionError
let evaluating = false
$: useSnippets = allowSnippets && !$licensing.isFreePlan
@@ -142,10 +143,22 @@
}
const debouncedEval = Utils.debounce((expression, context, snippets) => {
- expressionResult = processStringSync(expression || "", {
- ...context,
- snippets,
- })
+ try {
+ expressionError = null
+ expressionResult = processStringSync(
+ expression || "",
+ {
+ ...context,
+ snippets,
+ },
+ {
+ noThrow: false,
+ }
+ )
+ } catch (err) {
+ expressionResult = null
+ expressionError = err
+ }
evaluating = false
}, 260)
@@ -370,6 +383,7 @@
{:else if sidePanel === SidePanels.Evaluation}
diff --git a/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte b/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte
index 2c4e6a0991..ffb8f45297 100644
--- a/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte
+++ b/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte
@@ -3,26 +3,37 @@
import { Icon, ProgressCircle, notifications } from "@budibase/bbui"
import { copyToClipboard } from "@budibase/bbui/helpers"
import { fade } from "svelte/transition"
+ import { UserScriptError } from "@budibase/string-templates"
export let expressionResult
+ export let expressionError
export let evaluating = false
export let expression = null
- $: error = expressionResult === "Error while executing JS"
+ $: error = expressionError != null
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
+ const formatError = err => {
+ if (err.code === UserScriptError.code) {
+ return err.userScriptError.toString()
+ }
+ return err.toString()
+ }
+
const highlight = json => {
if (json == null) {
return ""
}
- // Attempt to parse and then stringify, in case this is valid JSON
+
+ // Attempt to parse and then stringify, in case this is valid result
try {
json = JSON.stringify(JSON.parse(json), null, 2)
} catch (err) {
// Ignore
}
+
return formatHighlight(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
@@ -34,7 +45,7 @@
}
const copy = () => {
- let clipboardVal = expressionResult
+ let clipboardVal = expressionResult.result
if (typeof clipboardVal === "object") {
clipboardVal = JSON.stringify(clipboardVal, null, 2)
}
@@ -73,6 +84,8 @@
{#if empty}
Your expression will be evaluated here
+ {:else if error}
+ {formatError(expressionError)}
{:else}
{@html highlightedResult}
diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
index 24851c723d..1ee96bf624 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte
@@ -63,13 +63,15 @@
$: rowActions.refreshRowActions(id)
const makeRowActionButtons = actions => {
- return (actions || []).map(action => ({
- text: action.name,
- onClick: async row => {
- await rowActions.trigger(id, action.id, row._id)
- notifications.success("Row action triggered successfully")
- },
- }))
+ return (actions || [])
+ .filter(action => action.allowedSources?.includes(id))
+ .map(action => ({
+ text: action.name,
+ onClick: async row => {
+ await rowActions.trigger(id, action.id, row._id)
+ notifications.success("Row action triggered successfully")
+ },
+ }))
}
const relationshipSupport = datasource => {
diff --git a/packages/builder/src/pages/builder/portal/apps/index.svelte b/packages/builder/src/pages/builder/portal/apps/index.svelte
index 9a073d041f..ddd37dc4a3 100644
--- a/packages/builder/src/pages/builder/portal/apps/index.svelte
+++ b/packages/builder/src/pages/builder/portal/apps/index.svelte
@@ -26,6 +26,7 @@
licensing,
environment,
enrichedApps,
+ sortBy,
} from "stores/portal"
import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte"
@@ -247,7 +248,7 @@