diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 3a58587f6e..40728d4910 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -235,15 +235,16 @@ function promptActiveDatabaseSelection() { toast(t("editor.selectDatabaseRequired"), 2500); } -const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, showSqlParameterDialog, sqlParameterSourceSql, sqlParameterNames, onSqlParametersConfirm, explainMode } = useSqlExecution({ - activeTab, - activeConnection, - executableSql, - resolveExecutableSql: resolveActiveExecutableSql, - activeOutputView, - blockDangerousRedisCommands, - onMissingDatabase: promptActiveDatabaseSelection, -}); +const { dangerSql, pendingDangerSql, showDangerDialog, suppressDangerConfirm, tryExecute, doExecute, cancelActiveExecution, tryExplain, onDangerConfirm, showSqlParameterDialog, sqlParameterSourceSql, sqlParameterNames, sqlParameterDatabaseType, onSqlParametersConfirm, explainMode } = + useSqlExecution({ + activeTab, + activeConnection, + executableSql, + resolveExecutableSql: resolveActiveExecutableSql, + activeOutputView, + blockDangerousRedisCommands, + onMissingDatabase: promptActiveDatabaseSelection, + }); function requestActiveEditorExecute() { if (contentAreaRef.value?.requestQueryEditorExecute?.()) return; @@ -1951,6 +1952,7 @@ onUnmounted(() => { :show-sql-parameter-dialog="showSqlParameterDialog" :sql-parameter-source-sql="sqlParameterSourceSql" :sql-parameter-names="sqlParameterNames" + :sql-parameter-database-type="sqlParameterDatabaseType" @update:show-connection-dialog="setConnectionDialogOpen" @update:show-danger-dialog="showDangerDialog = $event" @update:suppress-danger-confirm="suppressDangerConfirm = $event" diff --git a/apps/desktop/src/components/editor/SqlParameterDialog.vue b/apps/desktop/src/components/editor/SqlParameterDialog.vue index f1da7b13a9..c07ba33693 100644 --- a/apps/desktop/src/components/editor/SqlParameterDialog.vue +++ b/apps/desktop/src/components/editor/SqlParameterDialog.vue @@ -11,6 +11,7 @@ import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue"; import { loadSqlParameterHistory, rememberSqlParameterValues } from "@/lib/sql/sqlParameterHistory"; import { substituteSqlParameters, type SqlParameterDescriptor, type SqlParameterInput, type SqlParameterSyntax, type SqlParameterValueKind } from "@/lib/sql/sqlParameters"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; +import type { DatabaseType } from "@/types/database"; const { t } = useI18n(); const { highlight } = useSqlHighlighter(); @@ -20,6 +21,7 @@ const open = defineModel("open", { default: false }); const props = defineProps<{ sql: string; parameters: SqlParameterDescriptor[]; + databaseType?: DatabaseType; }>(); const emit = defineEmits<{ @@ -41,7 +43,7 @@ const syntaxLabels: Record = { sqlserver: "@name", }; -const resolvedSql = computed(() => substituteSqlParameters(props.sql, values.value)); +const resolvedSql = computed(() => substituteSqlParameters(props.sql, values.value, { databaseType: props.databaseType })); const highlightedSql = computed(() => highlight(resolvedSql.value)); watch( diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 1fff21a17e..74e32fee0c 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -22,6 +22,7 @@ import { useDialogSources } from "@/composables/useDialogSources"; import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import type { SqlParameterDescriptor } from "@/lib/sql/sqlParameters"; import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; +import type { DatabaseType } from "@/types/database"; const props = defineProps<{ showConnectionDialog: boolean; @@ -33,6 +34,7 @@ const props = defineProps<{ showSqlParameterDialog: boolean; sqlParameterSourceSql: string; sqlParameterNames: SqlParameterDescriptor[]; + sqlParameterDatabaseType?: DatabaseType; }>(); const emit = defineEmits<{ @@ -133,7 +135,7 @@ watch( @update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)" @confirm="emit('dangerConfirm')" /> - + ([]); + const sqlParameterDatabaseType = ref(); async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise { if (typeof source === "string") return source; @@ -107,10 +108,12 @@ export function useSqlExecution(deps: { } function prepareSqlParameterDialog(sql: string): boolean { - const parameters = extractSqlParameterDescriptors(sql); + const databaseType = deps.activeConnection.value?.db_type; + const parameters = extractSqlParameterDescriptors(sql, { databaseType }); if (!parameters.length) return false; sqlParameterSourceSql.value = sql; sqlParameterNames.value = parameters; + sqlParameterDatabaseType.value = databaseType; showSqlParameterDialog.value = true; return true; } @@ -201,9 +204,17 @@ export function useSqlExecution(deps: { showSqlParameterDialog.value = false; sqlParameterSourceSql.value = ""; sqlParameterNames.value = []; + sqlParameterDatabaseType.value = undefined; await continueExecute(sql); } + watch(showSqlParameterDialog, (open) => { + if (open) return; + sqlParameterSourceSql.value = ""; + sqlParameterNames.value = []; + sqlParameterDatabaseType.value = undefined; + }); + return { dangerSql, pendingDangerSql, @@ -217,6 +228,7 @@ export function useSqlExecution(deps: { showSqlParameterDialog, sqlParameterSourceSql, sqlParameterNames, + sqlParameterDatabaseType, onSqlParametersConfirm, explainMode, }; diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index ea262c9b53..3d77ac16fd 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -176,6 +176,11 @@ describe("extractSqlParameters", () => { const sql = "select value::int, value := 1, :actual_value"; expect(extractSqlParameters(sql)).toEqual(["actual_value"]); }); + + it("ignores HANA SQLScript variable references", () => { + const sql = "DO BEGIN Dummy1 = SELECT 1 FROM DUMMY; SELECT * FROM :Dummy1; END"; + expect(extractSqlParameters(sql, { databaseType: "saphana" })).toEqual([]); + }); }); describe("substituteSqlParameters", () => { @@ -264,6 +269,11 @@ DEALLOCATE PREPARE stmt;`; }), ).toBe("exec dbo.search_orders @date_start = '2026-07-04', @status = 'paid', @tenant_id = 7"); }); + + it("keeps HANA SQLScript variable references while replacing template parameters", () => { + const sql = "DO BEGIN Dummy1 = SELECT * FROM ORDERS WHERE TENANT_ID = ${tenant_id}; SELECT * FROM :Dummy1; END"; + expect(substituteSqlParameters(sql, { tenant_id: { kind: "number", value: "42" } }, { databaseType: "saphana" })).toBe("DO BEGIN Dummy1 = SELECT * FROM ORDERS WHERE TENANT_ID = 42; SELECT * FROM :Dummy1; END"); + }); }); describe("sqlParameterLiteral", () => { diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index 1cd61b1306..a3d6d9bdc7 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -1,3 +1,5 @@ +import type { DatabaseType } from "@/types/database"; + export type SqlParameterValueKind = "string" | "number" | "boolean" | "null" | "raw"; export interface SqlParameterInput { @@ -19,19 +21,23 @@ interface ParameterOccurrence extends SqlParameterDescriptor { end: number; } +export interface SqlParameterOptions { + databaseType?: DatabaseType; +} + const PARAMETER_NAME_RE = /^[\p{L}_][\p{L}\p{N}_]*$/u; const PARAMETER_NAME_START_RE = /[\p{L}_]/u; const PARAMETER_NAME_CHAR_RE = /[\p{L}\p{N}_]/u; const SQL_SERVER_TEMP_TABLE_CONTEXT_KEYWORDS = new Set(["table", "from", "join", "into", "update", "truncate"]); -export function extractSqlParameters(sql: string): string[] { - return extractSqlParameterDescriptors(sql).map((descriptor) => descriptor.key); +export function extractSqlParameters(sql: string, options?: SqlParameterOptions): string[] { + return extractSqlParameterDescriptors(sql, options).map((descriptor) => descriptor.key); } -export function extractSqlParameterDescriptors(sql: string): SqlParameterDescriptor[] { +export function extractSqlParameterDescriptors(sql: string, options?: SqlParameterOptions): SqlParameterDescriptor[] { const names = new Set(); const descriptors: SqlParameterDescriptor[] = []; - for (const occurrence of findSqlParameterOccurrences(sql)) { + for (const occurrence of findSqlParameterOccurrences(sql, options)) { if (names.has(occurrence.key)) continue; names.add(occurrence.key); descriptors.push({ @@ -44,8 +50,8 @@ export function extractSqlParameterDescriptors(sql: string): SqlParameterDescrip return descriptors; } -export function substituteSqlParameters(sql: string, values: Record): string { - const occurrences = findSqlParameterOccurrences(sql); +export function substituteSqlParameters(sql: string, values: Record, options?: SqlParameterOptions): string { + const occurrences = findSqlParameterOccurrences(sql, options); if (!occurrences.length) return sql; let result = ""; @@ -68,9 +74,10 @@ export function sqlParameterLiteral(input: SqlParameterInput): string { return quoteSqlString(raw); } -function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] { +function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions): ParameterOccurrence[] { const occurrences: ParameterOccurrence[] = []; const nativeSqlServerParameters = collectNativeSqlServerParameters(sql); + const supportsNamedParameters = options?.databaseType !== "saphana"; let i = 0; let dollarQuoteEnd = ""; let positionalIndex = 0; @@ -110,7 +117,7 @@ function findSqlParameterOccurrences(sql: string): ParameterOccurrence[] { i += 1; continue; } - if (ch === ":") { + if (ch === ":" && supportsNamedParameters) { const name = readParameterName(sql, i + 1); if (name && sql[i - 1] !== ":" && sql[i + 1] !== "=") { occurrences.push({