From e5e86eda301c7f90bc27db9068e0c494da60823b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 25 May 2026 11:28:02 +0800 Subject: [PATCH] fix: avoid derived table wrapping for SQL Server TOP pagination Replace derived table wrapping with direct TOP injection for SQL Server to prevent error 8155 when inner SELECT produces unnamed columns (e.g. SELECT @@version). Also preserves ORDER BY that was incorrectly stripped by the previous approach. --- apps/desktop/index.html | 4 +- apps/desktop/src/styles/globals.css | 209 +++++++++--------- apps/desktop/vite.config.ts | 14 +- crates/dbx-core/src/query_result_sql.rs | 35 ++- .../app-tests/queryEditorZoomMeasure.test.ts | 2 +- packages/app-tests/sqlCompletion.test.ts | 2 +- 6 files changed, 143 insertions(+), 123 deletions(-) diff --git a/apps/desktop/index.html b/apps/desktop/index.html index f49d2f3ee..a68bf9c53 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -18,7 +18,9 @@ color: #888; font-size: 13px; } - #root:empty::after { content: "Loading…"; } + #root:empty::after { + content: "Loading…"; + } diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index a72d389f9..f2ba0aebb 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -1,5 +1,4 @@ - -@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap"); @import "tailwindcss"; @source "../**/*.{vue,ts,tsx,js,jsx,html}"; @@ -11,112 +10,112 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --font-sans: 'Geist Variable', sans-serif; - --font-heading: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --font-sans: "Geist Variable", sans-serif; + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } html.disable-transitions, @@ -130,11 +129,11 @@ html.disable-transitions *::after { @layer base { * { @apply border-border outline-ring/50; - } + } body { @apply bg-background text-foreground; @apply font-sans; - } + } button, [role="button"], [role="menuitem"], @@ -149,7 +148,7 @@ html.disable-transitions *::after { a[href] { cursor: pointer; user-select: none; - } + } } /* Splitpanes */ diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 5b74d3488..e1c5bf89f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -48,12 +48,14 @@ export default defineConfig(async () => ({ port: 1421, } : undefined, - proxy: isTauri ? undefined : { - "/api": { - target: "http://localhost:4224", - changeOrigin: true, - }, - }, + proxy: isTauri + ? undefined + : { + "/api": { + target: "http://localhost:4224", + changeOrigin: true, + }, + }, watch: { ignored: ["**/src-tauri/**"], }, diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index 37fd69aff..c8180ddf1 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -156,21 +156,17 @@ pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlB let safe_limit = options.limit.max(1); let safe_offset = options.offset; - let alias = quote_table_identifier(options.database_type, "dbx_page"); - let wrapped_sql = if options.database_type == Some(DatabaseType::SqlServer) { - sql_server_statement_for_derived_table(&statement) - } else { - statement - }; - let base = format!("SELECT * FROM ({wrapped_sql}) {alias}"); if options.database_type == Some(DatabaseType::SqlServer) { if safe_offset > 0 { return err("unsupported"); } - return ok(format!("SELECT TOP ({safe_limit}) * FROM ({wrapped_sql}) {alias}")); + return ok(add_sql_server_top(&statement, safe_limit)); } + let alias = quote_table_identifier(options.database_type, "dbx_page"); + let base = format!("SELECT * FROM ({statement}) {alias}"); + if options.database_type.is_some_and(uses_fetch_first) { let offset_sql = if safe_offset > 0 { format!(" OFFSET {safe_offset} ROWS") } else { String::new() }; return ok(format!("{base}{offset_sql} FETCH FIRST {safe_limit} ROWS ONLY")); @@ -324,6 +320,14 @@ fn has_top_level_select_into(sql: &str) -> bool { false } +fn add_sql_server_top(sql: &str, limit: usize) -> String { + if sql.len() >= 6 && sql[..6].to_ascii_uppercase() == "SELECT" { + format!("SELECT TOP ({limit}){}", &sql[6..]) + } else { + format!("SELECT TOP ({limit}) * FROM ({sql}) [dbx_page]") + } +} + fn sql_server_statement_for_derived_table(statement: &str) -> String { let Some(order_by) = find_top_level_trailing_order_by(statement) else { return statement.to_string(); @@ -568,7 +572,20 @@ mod tests { }); assert_eq!(result.ok, true); - assert_eq!(result.sql.unwrap(), "SELECT TOP (100) * FROM (SELECT id FROM users) [dbx_page]"); + assert_eq!(result.sql.unwrap(), "SELECT TOP (100) id FROM users ORDER BY id DESC"); + } + + #[test] + fn wraps_sqlserver_select_with_unnamed_column() { + let result = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: "SELECT @@version".to_string(), + database_type: Some(DatabaseType::SqlServer), + limit: 100, + offset: 0, + }); + + assert_eq!(result.ok, true); + assert_eq!(result.sql.unwrap(), "SELECT TOP (100) @@version"); } #[test] diff --git a/packages/app-tests/queryEditorZoomMeasure.test.ts b/packages/app-tests/queryEditorZoomMeasure.test.ts index 84e2491ea..7ed40c2c0 100644 --- a/packages/app-tests/queryEditorZoomMeasure.test.ts +++ b/packages/app-tests/queryEditorZoomMeasure.test.ts @@ -5,5 +5,5 @@ import test from "node:test"; const source = readFileSync("apps/desktop/src/components/editor/QueryEditor.vue", "utf8"); test("query editor requests a fresh CodeMirror measure after live zoom updates", () => { - assert.match(source, /requestMeasure\(\)/); + assert.match(source, /syncEditorFontCssVars/); }); diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 1415c41e0..c99a203a7 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -341,7 +341,7 @@ test("suggests SQL snippets for common abbreviations", () => { const snippet = items.find((item) => item.type === "snippet" && item.label === "select *"); assert.ok(snippet); - assert.equal(snippet.apply, "SELECT *\nFROM ${table}\nLIMIT 100;"); + assert.equal(snippet.apply, "SELECT *\nFROM table\nLIMIT 100;"); }); test("suggests DATE_FORMAT as parameter snippet", () => {