fix(objects): save SQL Server object source definitions
This commit is contained in:
parent
3c0dbb3e30
commit
cb70625bd8
|
|
@ -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<Compat<TcpStream>>;
|
||||
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<Qu
|
|||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
} else if requires_simple_query_batch(sql) {
|
||||
client.simple_query(sql).await.map_err(|e| e.to_string())?.into_results().await.map_err(|e| e.to_string())?;
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
} else {
|
||||
let result = client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
Ok(QueryResult {
|
||||
|
|
@ -389,3 +399,84 @@ pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result<Qu
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_simple_query_batch(sql: &str) -> 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<String> {
|
||||
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;"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
src/App.vue
20
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)"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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<ObjectRow | null>(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<SqlFormatDialect>(() => {
|
||||
switch (props.connection.db_type) {
|
||||
case "mysql":
|
||||
case "postgres":
|
||||
case "sqlite":
|
||||
case "sqlserver":
|
||||
return props.connection.db_type;
|
||||
default:
|
||||
return "generic";
|
||||
}
|
||||
});
|
||||
const objectFilters = computed<ObjectFilter[]>(() =>
|
||||
(
|
||||
[
|
||||
|
|
@ -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(
|
|||
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||||
<Code2 class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ sourceTitle(sourceRow) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="copySource">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
|
||||
<PencilLine class="h-3 w-3" />
|
||||
<Button
|
||||
v-if="sourceEditing"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
:disabled="sourceSaving || !sourceDraft.trim()"
|
||||
@click="saveSource"
|
||||
>
|
||||
<Loader2 v-if="sourceSaving" class="mr-1 h-3 w-3 animate-spin" />
|
||||
{{ t("objects.saveSource") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="sourceEditing"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
:disabled="sourceSaving"
|
||||
@click="cancelEditSource"
|
||||
>
|
||||
{{ t("objects.cancelEdit") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!sourceEditing"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:class="{ 'bg-accent': sourceWrap }"
|
||||
@click="sourceWrap = !sourceWrap"
|
||||
:disabled="!sourceContent"
|
||||
@click="copySource"
|
||||
>
|
||||
<WrapText class="h-3 w-3" />
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!sourceEditing"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-5 w-5"
|
||||
:disabled="!sourceContent"
|
||||
@click="editSource"
|
||||
>
|
||||
<PencilLine class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
|
||||
<X class="h-3 w-3" />
|
||||
|
|
@ -406,12 +483,33 @@ watch(
|
|||
<div v-else-if="sourceError" class="flex flex-1 items-center justify-center px-4 text-sm text-destructive">
|
||||
{{ sourceError }}
|
||||
</div>
|
||||
<pre
|
||||
<div v-else-if="sourceEditing" class="flex min-h-0 flex-1 flex-col">
|
||||
<QueryEditor
|
||||
v-model="sourceDraft"
|
||||
class="min-h-0 flex-1"
|
||||
:connection-id="props.connection.id"
|
||||
:database="props.database"
|
||||
:dialect="sourceDialect"
|
||||
:format-dialect="sourceFormatDialect"
|
||||
force-word-wrap
|
||||
@execute="saveSource"
|
||||
/>
|
||||
<div v-if="sourceSaveError" class="shrink-0 border-t px-3 py-2 text-xs text-destructive">
|
||||
{{ sourceSaveError }}
|
||||
</div>
|
||||
</div>
|
||||
<QueryEditor
|
||||
v-else
|
||||
class="min-w-0 flex-1 overflow-auto p-3 font-mono text-xs leading-5"
|
||||
:class="sourceWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
|
||||
>{{ sourceContent }}</pre
|
||||
>
|
||||
: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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -554,6 +554,9 @@ export default {
|
|||
name: "名称",
|
||||
type: "类型",
|
||||
source: "源代码",
|
||||
saveSource: "保存",
|
||||
cancelEdit: "取消",
|
||||
sourceSaved: "源码已保存",
|
||||
schemaColumn: "Schema",
|
||||
comment: "注释",
|
||||
loadingSchemas: "加载 Schema...",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue