diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index f9a1d5436..4de0fed9c 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -9,6 +9,7 @@ use crate::sql::starts_with_executable_sql_keyword; use crate::types::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo}; pub type SqlServerClient = Client>; +const SIMPLE_QUERY_MODULE_KEYWORDS: &[&str] = &["FUNCTION", "PROC", "PROCEDURE", "TRIGGER", "VIEW"]; pub async fn connect( host: &str, @@ -378,6 +379,15 @@ pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result Result bool { + let tokens = first_sql_tokens(sql, 4); + if tokens.len() >= 4 + && tokens[0].eq_ignore_ascii_case("CREATE") + && tokens[1].eq_ignore_ascii_case("OR") + && tokens[2].eq_ignore_ascii_case("ALTER") + { + return SIMPLE_QUERY_MODULE_KEYWORDS.iter().any(|keyword| tokens[3].eq_ignore_ascii_case(keyword)); + } + + if tokens.len() >= 2 && (tokens[0].eq_ignore_ascii_case("CREATE") || tokens[0].eq_ignore_ascii_case("ALTER")) { + return SIMPLE_QUERY_MODULE_KEYWORDS.iter().any(|keyword| tokens[1].eq_ignore_ascii_case(keyword)); + } + + false +} + +fn first_sql_tokens(sql: &str, limit: usize) -> Vec { + let bytes = sql.as_bytes(); + let mut tokens = Vec::new(); + let mut i = 0; + + while i < bytes.len() && tokens.len() < limit { + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + + if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b'-' { + i += 2; + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + + if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + continue; + } + + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + + if i > start { + tokens.push(sql[start..i].to_string()); + } else { + i += 1; + } + } + + tokens +} + +#[cfg(test)] +mod tests { + use super::requires_simple_query_batch; + + #[test] + fn sqlserver_module_definitions_require_simple_query_batch() { + assert!(requires_simple_query_batch("CREATE FUNCTION dbo.fn_demo() RETURNS INT AS BEGIN RETURN 1; END;")); + assert!(requires_simple_query_batch("ALTER PROCEDURE dbo.usp_demo AS SELECT 1;")); + assert!(requires_simple_query_batch("CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;")); + assert!(requires_simple_query_batch( + "-- comment\nALTER TRIGGER dbo.tr_demo ON dbo.t AFTER INSERT AS SELECT 1;" + )); + } + + #[test] + fn sqlserver_regular_ddl_can_use_execute() { + assert!(!requires_simple_query_batch("ALTER TABLE dbo.t ADD name NVARCHAR(20);")); + assert!(!requires_simple_query_batch("CREATE TABLE dbo.t(id INT);")); + assert!(!requires_simple_query_batch("UPDATE dbo.t SET id = 1;")); + } +} diff --git a/src/App.vue b/src/App.vue index e90b6fb74..53fe0f658 100644 --- a/src/App.vue +++ b/src/App.vue @@ -319,25 +319,6 @@ async function onClickTable(tableName: string) { } } -function openObjectSourceEditor(target: { title: string; sql: string; schema?: string }) { - const tab = activeTab.value; - if (!tab) return; - const existing = queryStore.tabs.find( - (item) => - item.mode === "query" && - item.connectionId === tab.connectionId && - item.database === tab.database && - item.title === target.title, - ); - if (existing) { - queryStore.activeTabId = existing.id; - return; - } - const tabId = queryStore.createTab(tab.connectionId, tab.database, target.title); - if (target.schema) queryStore.updateSchema(tabId, target.schema); - queryStore.updateSql(tabId, target.sql); -} - async function changeActiveConnection(connectionId: string) { const tab = activeTab.value; if (!tab) return; @@ -659,7 +640,6 @@ onUnmounted(() => { tableName: target.tableName, }) " - @edit-object-source="openObjectSourceEditor" @object-schema-change="(schema) => activeTab && queryStore.updateSchema(activeTab.id, schema)" /> diff --git a/src/components/editor/QueryEditor.vue b/src/components/editor/QueryEditor.vue index 7a3f7b689..fc658666b 100644 --- a/src/components/editor/QueryEditor.vue +++ b/src/components/editor/QueryEditor.vue @@ -18,6 +18,8 @@ const props = defineProps<{ dialect?: "mysql" | "postgres" | "sqlserver"; formatDialect?: SqlFormatDialect; formatRequestId?: number; + readOnly?: boolean; + forceWordWrap?: boolean; }>(); const emit = defineEmits<{ @@ -41,6 +43,7 @@ let editorViewModule: typeof import("@codemirror/view") | null = null; let fontThemeComp: import("@codemirror/state").Compartment | null = null; let codeMirrorTheme: import("@codemirror/state").Compartment | null = null; let wordWrapComp: import("@codemirror/state").Compartment | null = null; +let readOnlyComp: import("@codemirror/state").Compartment | null = null; // Completion cache let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = []; @@ -78,6 +81,11 @@ function resetZoom() { setFontSize(13); } +function wordWrapExtension() { + if (!editorViewModule) return []; + return props.forceWordWrap || settingsStore.editorSettings.wordWrap ? editorViewModule.EditorView.lineWrapping : []; +} + function selectedSqlFromView(currentView: EditorViewType): string { const selection = currentView.state.selection.main; return currentView.state.sliceDoc(selection.from, selection.to); @@ -226,6 +234,7 @@ onMounted(async () => { fontThemeComp = new Compartment(); codeMirrorTheme = new Compartment(); wordWrapComp = new Compartment(); + readOnlyComp = new Compartment(); const ss = settingsStore.editorSettings; @@ -291,7 +300,8 @@ onMounted(async () => { bracketMatching(), Prec.highest(keymap.of([...closeBracketsKeymap, indentWithTab])), runKeymap, - wordWrapComp.of(ss.wordWrap ? EditorView.lineWrapping : []), + wordWrapComp.of(props.forceWordWrap || ss.wordWrap ? EditorView.lineWrapping : []), + readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]), EditorView.updateListener.of((update) => { if (update.docChanged) { emit("update:modelValue", update.state.doc.toString()); @@ -489,6 +499,16 @@ watch( }, ); +watch( + () => props.forceWordWrap, + () => { + if (!view.value || !wordWrapComp) return; + view.value.dispatch({ + effects: wordWrapComp.reconfigure(wordWrapExtension()), + }); + }, +); + // Reactively apply editor settings changes watch( () => settingsStore.editorSettings, @@ -498,7 +518,7 @@ watch( view.value.dispatch({ effects: [ codeMirrorTheme.reconfigure(themeExt), - wordWrapComp.reconfigure(ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), + wordWrapComp.reconfigure(props.forceWordWrap || ss.wordWrap ? editorViewModule.EditorView.lineWrapping : []), fontThemeComp.reconfigure( editorFontTheme(editorViewModule.EditorView, ss.fontSize, ss.fontFamily, { fixedHeight: true, diff --git a/src/components/layout/ContentArea.vue b/src/components/layout/ContentArea.vue index 690654051..282fc0c36 100644 --- a/src/components/layout/ContentArea.vue +++ b/src/components/layout/ContentArea.vue @@ -46,7 +46,6 @@ const emit = defineEmits<{ executeSql: [sql: string]; clickTable: [tableName: string]; openObjectTable: [target: { tableName: string; schema?: string }]; - editObjectSource: [target: { title: string; sql: string; schema?: string }]; objectSchemaChange: [schema: string | undefined]; }>(); @@ -443,7 +442,6 @@ function onHandleCloseColumnPanel() { :database="activeTab.database" :schema="activeTab.objectBrowser?.schema" @open-table="emit('openObjectTable', $event)" - @edit-source="emit('editObjectSource', $event)" @schema-change="emit('objectSchemaChange', $event)" /> diff --git a/src/components/objects/ObjectBrowser.vue b/src/components/objects/ObjectBrowser.vue index 492f965fc..214fc5360 100644 --- a/src/components/objects/ObjectBrowser.vue +++ b/src/components/objects/ObjectBrowser.vue @@ -12,7 +12,6 @@ import { Search, ScrollText, Table2, - WrapText, X, } from "lucide-vue-next"; import { useI18n } from "vue-i18n"; @@ -23,7 +22,9 @@ import * as api from "@/lib/api"; import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database"; import { isSchemaAware } from "@/lib/databaseCapabilities"; import { useToast } from "@/composables/useToast"; -import { buildEditableObjectSourceSql, objectSourceEditTabTitle } from "@/lib/objectSourceEditor"; +import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; +import QueryEditor from "@/components/editor/QueryEditor.vue"; +import type { SqlFormatDialect } from "@/lib/sqlFormatter"; type ObjectRow = { id: string; @@ -44,7 +45,6 @@ const props = defineProps<{ const emit = defineEmits<{ openTable: [target: { tableName: string; schema?: string }]; schemaChange: [schema: string | undefined]; - editSource: [target: { title: string; sql: string; schema?: string }]; }>(); const { t } = useI18n(); @@ -61,7 +61,10 @@ const sourceLoading = ref(false); const sourceContent = ref(""); const sourceError = ref(""); const sourceRow = ref(null); -const sourceWrap = ref(false); +const sourceEditing = ref(false); +const sourceDraft = ref(""); +const sourceSaving = ref(false); +const sourceSaveError = ref(""); const error = ref(""); let loadId = 0; @@ -70,6 +73,22 @@ const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length); const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length); const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length); +const sourceDialect = computed<"mysql" | "postgres" | "sqlserver">(() => { + if (props.connection.db_type === "postgres" || props.connection.db_type === "gaussdb") return "postgres"; + if (props.connection.db_type === "sqlserver") return "sqlserver"; + return "mysql"; +}); +const sourceFormatDialect = computed(() => { + switch (props.connection.db_type) { + case "mysql": + case "postgres": + case "sqlite": + case "sqlserver": + return props.connection.db_type; + default: + return "generic"; + } +}); const objectFilters = computed(() => ( [ @@ -157,6 +176,9 @@ async function openSource(row: ObjectRow) { sourceRow.value = row; sourceContent.value = ""; sourceError.value = ""; + sourceEditing.value = false; + sourceDraft.value = ""; + sourceSaveError.value = ""; sourceLoading.value = true; try { const result = await api.getObjectSource( @@ -178,6 +200,9 @@ function closeSource() { sourceRow.value = null; sourceContent.value = ""; sourceError.value = ""; + sourceEditing.value = false; + sourceDraft.value = ""; + sourceSaveError.value = ""; } function copySource() { @@ -188,19 +213,45 @@ function copySource() { function editSource() { if (!sourceRow.value || !sourceContent.value) return; + sourceDraft.value = sourceContent.value; + sourceSaveError.value = ""; + sourceEditing.value = true; +} + +function cancelEditSource() { + sourceEditing.value = false; + sourceDraft.value = ""; + sourceSaveError.value = ""; +} + +async function saveSource() { + if (!sourceRow.value || !sourceDraft.value.trim()) return; const row = sourceRow.value; - const schema = row.schema || selectedSchema.value; - emit("editSource", { - title: objectSourceEditTabTitle(schema, row.name), - schema, - sql: buildEditableObjectSourceSql({ + const schema = row.schema || selectedSchema.value || props.database; + sourceSaving.value = true; + sourceSaveError.value = ""; + try { + const sql = buildExecutableObjectSourceSql({ databaseType: props.connection.db_type, objectType: row.type as ObjectSourceKind, schema, name: row.name, - source: sourceContent.value, - }), - }); + source: sourceDraft.value, + }); + if (objectSourceSaveExecutionMode(props.connection.db_type) === "single") { + await api.executeQuery(props.connection.id, props.database, sql, schema); + } else { + await api.executeScript(props.connection.id, props.database, sql, schema); + } + toast(t("objects.sourceSaved")); + sourceEditing.value = false; + sourceDraft.value = ""; + await openSource(row); + } catch (e: any) { + sourceSaveError.value = e?.message || String(e); + } finally { + sourceSaving.value = false; + } } async function loadSchemas() { @@ -381,20 +432,46 @@ watch(
{{ sourceTitle(sourceRow) }} - - + +
+ {{ sourceContent }} + :key="`source-preview-${sourceRow.id}`" + :model-value="sourceContent" + class="min-h-0 flex-1" + :connection-id="props.connection.id" + :database="props.database" + :dialect="sourceDialect" + :format-dialect="sourceFormatDialect" + force-word-wrap + read-only + /> diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9afea2a63..475b2385c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -567,6 +567,9 @@ export default { name: "Name", type: "Type", source: "Source", + saveSource: "Save", + cancelEdit: "Cancel", + sourceSaved: "Source saved", schemaColumn: "Schema", comment: "Comment", loadingSchemas: "Loading schemas...", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 3e75eeaf0..dd922cd1e 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -146,7 +146,8 @@ export default { jdbcUrl: "URL JDBC", jdbcUrlPlaceholder: "jdbc:postgresql://localhost:5432/base_de_datos", jdbcDriverClass: "Clase del driver (opcional)", - jdbcDriverClassPlaceholder: "La mayoría de los drivers se registran automáticamente; usa com.vendor.jdbc.Driver si es necesario", + jdbcDriverClassPlaceholder: + "La mayoría de los drivers se registran automáticamente; usa com.vendor.jdbc.Driver si es necesario", jdbcDriverPaths: "JARs del driver JDBC", jdbcDriverSelectPlaceholder: "Seleccionar driver importado", jdbcDriverPathsPlaceholder: "/ruta/al/driver.jar\n/ruta/a/otro-driver.jar", @@ -299,7 +300,8 @@ export default { }, welcome: { title: "Espacio de trabajo de base de datos", - subtitle: "Selecciona una conexión a la izquierda para explorar el esquema, o abre una pestaña de consulta directamente.", + subtitle: + "Selecciona una conexión a la izquierda para explorar el esquema, o abre una pestaña de consulta directamente.", connections: "Conexiones", connected: "Conectado", databaseTypes: "Tipos de bases de datos", @@ -363,14 +365,18 @@ export default { sameName: "Mismo nombre", }, description: { - foreignKeyIncoming: "{target} apunta al campo actual mediante una clave foránea. Esta es una dependencia verificada.", - foreignKeyOutgoing: "El campo actual referencia a {target} mediante una clave foránea. Esta es una dependencia verificada.", - viewLikely: "La definición de la vista menciona tanto la tabla como el campo destino, lo que generalmente indica una dependencia de consulta.", + foreignKeyIncoming: + "{target} apunta al campo actual mediante una clave foránea. Esta es una dependencia verificada.", + foreignKeyOutgoing: + "El campo actual referencia a {target} mediante una clave foránea. Esta es una dependencia verificada.", + viewLikely: + "La definición de la vista menciona tanto la tabla como el campo destino, lo que generalmente indica una dependencia de consulta.", viewPossible: "La definición de la vista menciona un campo con el mismo nombre pero no la tabla destino, por lo que requiere confirmación.", historyLikely: "Una sentencia SQL del historial menciona tanto la tabla como el campo destino. Úsala como contexto de análisis de impacto.", - historyPossible: "Una sentencia SQL del historial menciona un campo con el mismo nombre. Puede estar relacionado pero requiere verificación.", + historyPossible: + "Una sentencia SQL del historial menciona un campo con el mismo nombre. Puede estar relacionado pero requiere verificación.", sameName: "Otra tabla tiene un campo con el mismo nombre. Puede compartir significado de negocio, pero no es una dependencia verificada en la base de datos.", }, @@ -462,7 +468,8 @@ export default { fixWithAi: "Corregir con IA", truncated: "Contexto truncado", contextSummary: "{database} · {tables} tablas", - autoSqlBlocked: "El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.", + autoSqlBlocked: + "El SQL generado por la IA parecía demasiado riesgoso para ejecutarse automáticamente. Revísalo manualmente antes de ejecutar.", settingsHint: "La configuración se almacena en el directorio de datos local de la aplicación. Las solicitudes son enviadas por el backend de Tauri en lugar de directamente desde el frontend.", actions: { @@ -566,6 +573,10 @@ export default { function: "Función", name: "Nombre", type: "Tipo", + source: "Código fuente", + saveSource: "Guardar", + cancelEdit: "Cancelar", + sourceSaved: "Código fuente guardado", schemaColumn: "Esquema", comment: "Comentario", loadingSchemas: "Cargando esquemas...", @@ -724,10 +735,12 @@ export default { }, dangerDialog: { title: "Operación peligrosa", - message: "Esta sentencia SQL puede modificar o eliminar datos de forma irreversible. ¿Estás seguro de que deseas ejecutarla?", + message: + "Esta sentencia SQL puede modificar o eliminar datos de forma irreversible. ¿Estás seguro de que deseas ejecutarla?", deleteMessage: "Esta operación de eliminación puede ser irreversible. ¿Continuar?", deleteConfirm: "Confirmar eliminación", - deleteRowMessage: "Esta fila quedará marcada para eliminación y se borrará de la base de datos al guardar. ¿Continuar?", + deleteRowMessage: + "Esta fila quedará marcada para eliminación y se borrará de la base de datos al guardar. ¿Continuar?", deleteRowDetails: "Tabla: {table}", deleteRowDetailsNoTable: "Fila actual del resultado", redisKeyDetails: "Clave Redis: {key}", @@ -914,4 +927,4 @@ export default { project: "Proyecto", openSource: "Repositorio de código abierto", }, -}; \ No newline at end of file +}; diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 2062e405a..bdac12b82 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -554,6 +554,9 @@ export default { name: "名称", type: "类型", source: "源代码", + saveSource: "保存", + cancelEdit: "取消", + sourceSaved: "源码已保存", schemaColumn: "Schema", comment: "注释", loadingSchemas: "加载 Schema...", diff --git a/src/lib/objectSourceEditor.ts b/src/lib/objectSourceEditor.ts index 62d95409a..eb62fce45 100644 --- a/src/lib/objectSourceEditor.ts +++ b/src/lib/objectSourceEditor.ts @@ -8,6 +8,8 @@ type BuildEditableObjectSourceSqlInput = { source: string; }; +export type ObjectSourceSaveExecutionMode = "single" | "script"; + function quotePostgresIdentifier(value: string) { return `"${value.replaceAll('"', '""')}"`; } @@ -24,14 +26,10 @@ function postgresQualifiedName(schema: string | null | undefined, name: string) .join("."); } -export function objectSourceEditTabTitle(schema: string | null | undefined, name: string) { - return `Edit source - ${[schema, name].filter(Boolean).join(".")}`; -} - -export function buildEditableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) { +export function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceSqlInput) { const source = input.source.trim(); if (input.databaseType === "sqlserver") { - return source.replace(/^CREATE\s+(?!OR\s+ALTER\b)/i, "CREATE OR ALTER "); + return source.replace(/^CREATE\s+(?:OR\s+ALTER\s+)?/i, "ALTER "); } if ((input.databaseType === "postgres" || input.databaseType === "gaussdb") && input.objectType === "VIEW") { @@ -40,3 +38,7 @@ export function buildEditableObjectSourceSql(input: BuildEditableObjectSourceSql return ensureSemicolon(source); } + +export function objectSourceSaveExecutionMode(databaseType: DatabaseType): ObjectSourceSaveExecutionMode { + return databaseType === "sqlserver" ? "single" : "script"; +} diff --git a/tests/objectSourceEditor.test.ts b/tests/objectSourceEditor.test.ts index 774a425cf..657bdea75 100644 --- a/tests/objectSourceEditor.test.ts +++ b/tests/objectSourceEditor.test.ts @@ -1,9 +1,9 @@ import { strict as assert } from "node:assert"; import test from "node:test"; -import { buildEditableObjectSourceSql, objectSourceEditTabTitle } from "../src/lib/objectSourceEditor.ts"; +import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "../src/lib/objectSourceEditor.ts"; -test("SQL Server object source opens as CREATE OR ALTER", () => { - const sql = buildEditableObjectSourceSql({ +test("SQL Server edited source saves as ALTER", () => { + const sql = buildExecutableObjectSourceSql({ databaseType: "sqlserver", objectType: "PROCEDURE", schema: "dbo", @@ -11,11 +11,11 @@ test("SQL Server object source opens as CREATE OR ALTER", () => { source: "CREATE PROCEDURE dbo.usp_demo AS SELECT 1;", }); - assert.equal(sql, "CREATE OR ALTER PROCEDURE dbo.usp_demo AS SELECT 1;"); + assert.equal(sql, "ALTER PROCEDURE dbo.usp_demo AS SELECT 1;"); }); -test("SQL Server existing CREATE OR ALTER source is preserved", () => { - const sql = buildEditableObjectSourceSql({ +test("SQL Server edited CREATE OR ALTER source saves as ALTER", () => { + const sql = buildExecutableObjectSourceSql({ databaseType: "sqlserver", objectType: "VIEW", schema: "dbo", @@ -23,11 +23,15 @@ test("SQL Server existing CREATE OR ALTER source is preserved", () => { source: "CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;", }); - assert.equal(sql, "CREATE OR ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;"); + assert.equal(sql, "ALTER VIEW dbo.vw_demo AS SELECT 1 AS id;"); +}); + +test("SQL Server object source saves as a single batch", () => { + assert.equal(objectSourceSaveExecutionMode("sqlserver"), "single"); }); test("Postgres view body opens as CREATE OR REPLACE VIEW", () => { - const sql = buildEditableObjectSourceSql({ + const sql = buildExecutableObjectSourceSql({ databaseType: "postgres", objectType: "VIEW", schema: "public", @@ -37,8 +41,3 @@ test("Postgres view body opens as CREATE OR REPLACE VIEW", () => { assert.equal(sql, 'CREATE OR REPLACE VIEW "public"."active users" AS\nSELECT id, name FROM users WHERE active;'); }); - -test("object source edit tab title is stable per schema and object", () => { - assert.equal(objectSourceEditTabTitle("dbo", "usp_demo"), "Edit source - dbo.usp_demo"); - assert.equal(objectSourceEditTabTitle(undefined, "usp_demo"), "Edit source - usp_demo"); -});