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 @@