diff --git a/apps/desktop/src/components/editor/SqlParameterDialog.vue b/apps/desktop/src/components/editor/SqlParameterDialog.vue index ed77b65532..0528042d36 100644 --- a/apps/desktop/src/components/editor/SqlParameterDialog.vue +++ b/apps/desktop/src/components/editor/SqlParameterDialog.vue @@ -77,6 +77,15 @@ function updateKind(name: string, kind: SqlParameterValueKind) { values.value[name] = { ...current, kind, value }; } +function setAllParametersToRaw() { + const next = { ...values.value }; + for (const parameter of props.parameters) { + const current = next[parameter.key] ?? { kind: "string", value: "" }; + next[parameter.key] = { ...current, kind: "raw" }; + } + values.value = next; +} + function updateValue(name: string, value: string) { const matchedHistory = histories.value[name]?.find((entry) => entry.value === value); values.value[name] = { ...(values.value[name] ?? { kind: "string" }), ...(matchedHistory ? { kind: matchedHistory.kind } : {}), value }; @@ -135,7 +144,12 @@ async function copyResolvedSql() {
-

{{ t("sqlParameters.description") }}

+
+

{{ t("sqlParameters.description") }}

+ +
diff --git a/apps/desktop/src/components/editor/__tests__/SqlParameterDialog.spec.ts b/apps/desktop/src/components/editor/__tests__/SqlParameterDialog.spec.ts new file mode 100644 index 0000000000..21c9257ebc --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/SqlParameterDialog.spec.ts @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import SqlParameterDialog from "@/components/editor/SqlParameterDialog.vue"; +import type { SqlParameterDescriptor, SqlParameterInput } from "@/lib/sql/sqlParameters"; + +const historyMocks = vi.hoisted(() => ({ + load: vi.fn<(key: string) => SqlParameterInput[]>(), + remember: vi.fn<(values: Record) => Record>(), +})); + +vi.mock("@/lib/sql/sqlParameterHistory", () => ({ + loadSqlParameterHistory: historyMocks.load, + rememberSqlParameterValues: historyMocks.remember, +})); + +vi.mock("@/composables/useSqlHighlighter", () => ({ + useSqlHighlighter: () => ({ highlight: (sql: string) => `${sql}` }), +})); + +const SQL = "SELECT ${text_value}, ${numeric_value}, ${null_value}, ${raw_value}, ${empty_value}, ${text_value}"; +const PARAMETERS: SqlParameterDescriptor[] = [ + { key: "text_value", name: "text_value", syntax: "shell", token: "${text_value}" }, + { key: "numeric_value", name: "numeric_value", syntax: "shell", token: "${numeric_value}" }, + { key: "null_value", name: "null_value", syntax: "shell", token: "${null_value}" }, + { key: "raw_value", name: "raw_value", syntax: "shell", token: "${raw_value}" }, + { key: "empty_value", name: "empty_value", syntax: "shell", token: "${empty_value}" }, +]; +const MIXED_VALUES: Record = { + text_value: { kind: "string", value: "alpha beta" }, + numeric_value: { kind: "number", value: " 42 " }, + null_value: { kind: "null", value: "NULL" }, + raw_value: { kind: "raw", value: "current_date" }, + empty_value: { kind: "boolean", value: "" }, +}; +const RAW_VALUES = Object.fromEntries(Object.entries(MIXED_VALUES).map(([key, input]) => [key, { ...input, kind: "raw" as const }])); +const RAW_SQL = "SELECT alpha beta, 42, NULL, current_date, NULL, alpha beta"; + +const mountedApps: App[] = []; + +async function mountDialog(values: Record = MIXED_VALUES) { + historyMocks.load.mockImplementation((key) => (values[key] ? [{ ...values[key] }] : [])); + const state = reactive({ open: true }); + const onExecute = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup: () => () => + h(SqlParameterDialog, { + open: state.open, + sql: SQL, + parameters: PARAMETERS, + databaseType: "mysql", + enabledSyntaxes: ["shell"], + onExecute, + "onUpdate:open": (value: boolean) => { + state.open = value; + }, + }), + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(container); + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { state, onExecute }; +} + +function actionButton(): HTMLButtonElement | null { + return document.body.querySelector('[data-testid="sql-parameters-use-raw-all"]'); +} + +function clickRawAction() { + const button = actionButton(); + expect(button).not.toBeNull(); + button!.click(); +} + +function buttonWithText(text: string): HTMLButtonElement | undefined { + return Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.trim() === text); +} + +function parameterInputs(): HTMLInputElement[] { + return Array.from(document.body.querySelectorAll('input[data-form-type="other"]')); +} + +beforeEach(() => { + historyMocks.load.mockReset(); + historyMocks.remember.mockReset(); + historyMocks.remember.mockImplementation((values) => Object.fromEntries(Object.entries(values).map(([key, input]) => [key, [{ ...input }]]))); +}); + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("SqlParameterDialog raw parameter action", () => { + it("sets every displayed parameter to raw while preserving each current value", async () => { + const { state, onExecute } = await mountDialog(); + const beforeValues = parameterInputs().map((input) => input.value); + expect(parameterInputs()[2]?.disabled).toBe(true); + + clickRawAction(); + await nextTick(); + + const inputs = parameterInputs(); + expect(inputs.map((input) => input.value)).toEqual(beforeValues); + expect(inputs[2]?.disabled).toBe(false); + expect(Array.from(document.body.querySelectorAll('[role="combobox"]')).map((trigger) => trigger.textContent?.trim())).toEqual(Array(PARAMETERS.length).fill("Raw SQL")); + expect(document.body.querySelector("pre")?.textContent).toBe(RAW_SQL); + expect(state.open).toBe(true); + expect(onExecute).not.toHaveBeenCalled(); + expect(historyMocks.remember).not.toHaveBeenCalled(); + }); + + it("keeps the action idempotent when every displayed parameter is already raw", async () => { + const { state, onExecute } = await mountDialog(RAW_VALUES); + const beforeValues = parameterInputs().map((input) => input.value); + const beforePreview = document.body.querySelector("pre")?.textContent; + + clickRawAction(); + await nextTick(); + + expect(parameterInputs().map((input) => input.value)).toEqual(beforeValues); + expect(document.body.querySelector("pre")?.textContent).toBe(beforePreview); + expect(state.open).toBe(true); + expect(onExecute).not.toHaveBeenCalled(); + expect(historyMocks.remember).not.toHaveBeenCalled(); + }); + + it("keeps cancel side-effect free after applying raw to all parameters", async () => { + const { state, onExecute } = await mountDialog(); + + clickRawAction(); + await nextTick(); + buttonWithText("Cancel")?.click(); + await nextTick(); + + expect(state.open).toBe(false); + expect(onExecute).not.toHaveBeenCalled(); + expect(historyMocks.remember).not.toHaveBeenCalled(); + }); + + it("remembers and emits the resolved SQL only when Execute is clicked", async () => { + const { state, onExecute } = await mountDialog(); + + clickRawAction(); + await nextTick(); + expect(onExecute).not.toHaveBeenCalled(); + expect(historyMocks.remember).not.toHaveBeenCalled(); + + buttonWithText("Execute")?.click(); + await nextTick(); + + expect(historyMocks.remember).toHaveBeenCalledOnce(); + expect(historyMocks.remember).toHaveBeenCalledWith(RAW_VALUES); + expect(onExecute).toHaveBeenCalledOnce(); + expect(onExecute).toHaveBeenCalledWith(RAW_SQL); + expect(state.open).toBe(false); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5b852f0a11..3578a73642 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -5096,6 +5096,7 @@ export default { collectionValuePlaceholder: "Enter a JSON array or comma-separated values", preview: "SQL Preview", execute: "Execute", + useRawForAll: "Use Raw SQL for All", kind: { string: "String", number: "Number", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 1c69b285ab..09a574bb55 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -4860,6 +4860,7 @@ export default withEnglishFallback({ collectionValuePlaceholder: "Introduce un array JSON o valores separados por comas", preview: "Vista previa SQL", execute: "Ejecutar", + useRawForAll: "Usar SQL sin procesar para todos", kind: { string: "Cadena", number: "Número", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a94b457553..fb5f3aa6d0 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -4858,6 +4858,7 @@ export default withEnglishFallback({ collectionValuePlaceholder: "Inserisci un array JSON o valori separati da virgole", preview: "Anteprima SQL", execute: "Esegui", + useRawForAll: "Usa SQL non elaborato per tutti", kind: { string: "Stringa", number: "Numero", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e5df0fbedb..3a57435849 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4887,6 +4887,7 @@ export default withEnglishFallback({ collectionValuePlaceholder: "JSON 配列またはカンマ区切りの値を入力", preview: "SQLプレビュー", execute: "実行", + useRawForAll: "すべてを生の SQL に設定", kind: { string: "文字列", number: "数値", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 86f8c38097..95e3d8627d 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4531,6 +4531,7 @@ export default withEnglishFallback({ collectionValuePlaceholder: "JSON 배열 또는 쉼표로 구분된 값 입력", preview: "SQL 미리보기", execute: "실행", + useRawForAll: "모두 원시 SQL로 설정", kind: { string: "문자열", number: "숫자", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e7b3b82705..2b8abc7599 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -4860,6 +4860,7 @@ export default withEnglishFallback({ collectionValuePlaceholder: "Insira um array JSON ou valores separados por vírgula", preview: "Prévia SQL", execute: "Executar", + useRawForAll: "Usar SQL bruto para todos", kind: { string: "Texto", number: "Número", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 733a9dfc80..5632c6f137 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5081,6 +5081,7 @@ export default withEnglishFallback({ collectionValuePlaceholder: "输入 JSON 数组或逗号分隔值", preview: "SQL 预览", execute: "执行", + useRawForAll: "全部使用原始 SQL", kind: { string: "字符串", number: "数字", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 428f2cc87b..2dbcab1e82 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4184,6 +4184,7 @@ export default withEnglishFallback({ collectionValuePlaceholder: "輸入 JSON 陣列或逗號分隔值", preview: "SQL 預覽", execute: "執行", + useRawForAll: "全部使用原始 SQL", kind: { string: "字串", number: "數字",