feat(database): add database creation and property editing

This commit is contained in:
t8y2 committed 2026-07-06 02:00:01 +08:00
1 parent 3fdcc2d454
commit 2bfd03cca4
23 files changed
+1101 -57

No files matched your search

+278 -29
View File
@@ -80,7 +80,23 @@ import { clearActiveTableReferencePayload, createTableReferencePayload, createTa
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
import { getCachedTableMetadata, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS, tableMetadataToDataTabMeta } from "@/lib/metadata/tableMetadataCache";
import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import {
canCreateConnectionNamespace,
canCreateDatabaseNodeNamespace,
canEditDatabaseProperties as canEditDatabasePropertiesForNode,
connectionNamespaceCreationTarget,
editableDatabasePropertyGroups,
supportsDatabaseCreation,
supportsDatabaseSearch,
supportsFieldLineage,
supportsObjectBrowserTreeNode,
supportsSchemaDiagram,
supportsSqlFileExecution,
supportsTableImport,
supportsTableTruncate,
supportsTableStructureEditing,
usesTreeSchemaMode,
} from "@/lib/database/databaseCapabilities";
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
import { formatSqlInsert } from "@/lib/export/exportFormats";
@@ -93,13 +109,14 @@ import {
buildDropDatabaseSql,
buildDropObjectSql,
buildDropSchemaSql,
buildGetDatabaseCommentSql,
buildGetSchemaCommentSql,
buildUpdateDatabasePropertiesSql,
buildDropTableSql,
buildDropTableChildObjectSql,
buildDuplicateTableStructureSql,
buildCopyTableDataSql,
buildEmptyTableSql,
buildSetSchemaCommentSql,
buildTruncateTableSql,
supportsSchemaComment,
type DropTableChildObjectSqlOptions,
@@ -1753,6 +1770,12 @@ const showFlushRedisDbConfirm = ref(false);
const showCreateSchemaDialog = ref(false);
const createSchemaName = ref("");
const showDropSchemaConfirm = ref(false);
const showEditDatabasePropertiesDialog = ref(false);
const editDatabasePropertiesLoading = ref(false);
const editDatabasePropertiesPreviewSql = ref("");
const editDatabaseCharset = ref("utf8mb4");
const editDatabaseCollation = ref("utf8mb4_unicode_ci");
const editDatabaseCommentText = ref("");
const showEditSchemaCommentDialog = ref(false);
const schemaCommentText = ref("");
const schemaCommentLoading = ref(false);
@@ -2326,7 +2349,7 @@ const canCreateTable = computed(() => {
const canCreateDatabase = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return props.node.type === "connection" && (supportsDatabaseCreation(config?.db_type) || config?.db_type === "duckdb" || (config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy"));
return props.node.type === "connection" && canCreateConnectionNamespace(config);
});
const canCreateNacosNamespace = computed(() => {
@@ -2342,12 +2365,12 @@ const canEditNacosNamespace = computed(() => {
const isDuckDbConnection = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return props.node.type === "connection" && config?.db_type === "duckdb";
return props.node.type === "connection" && connectionNamespaceCreationTarget(config) === "attach";
});
const canSetCreateDatabaseCharset = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return supportsCreateDatabaseCharset(config?.db_type, config?.driver_profile);
return connectionNamespaceCreationTarget(config) === "database" && supportsCreateDatabaseCharset(config?.db_type, config?.driver_profile);
});
const canDropDatabase = computed(() => {
@@ -2355,6 +2378,19 @@ const canDropDatabase = computed(() => {
return props.node.type === "database" && !isSqlServerLinkedNode(props.node) && supportsDatabaseCreation(config?.db_type);
});
const databasePropertyGroups = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return editableDatabasePropertyGroups(config, props.node);
});
const canEditDatabaseProperties = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return canEditDatabasePropertiesForNode(config, props.node) && !isSqlServerLinkedNode(props.node);
});
const canEditDatabaseCharsetCollation = computed(() => databasePropertyGroups.value.includes("charsetCollation"));
const canEditDatabaseComment = computed(() => databasePropertyGroups.value.includes("databaseComment"));
const canDropMongoDatabase = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return props.node.type === "mongo-db" && !!props.node.database && config?.driver_profile !== "mongodb-legacy";
@@ -2393,7 +2429,7 @@ function mongoDropAllIndexesPreview(node: Pick<TreeNode, "label">): string {
const canCreateSchema = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return props.node.type === "database" && usesTreeSchemaMode(effectiveDatabaseTypeForConnection(config)) && !connectionUsesDatabaseObjectTreeMode(config);
return canCreateDatabaseNodeNamespace(config, props.node) && !isSqlServerLinkedNode(props.node) && !connectionUsesDatabaseObjectTreeMode(config);
});
const canDropSchema = computed(() => {
@@ -2510,32 +2546,151 @@ async function refreshDropSchemaPreviewSql() {
}).catch(() => "");
}
const schemaCommentPreviewSql = computed(() => {
if (!canEditSchemaComment.value) return "";
try {
return buildSetSchemaCommentSql({
databaseType: currentDatabaseType(),
name: props.node.schema || props.node.label,
comment: schemaCommentText.value,
});
} catch {
return "";
}
});
function databasePropertyName(): string {
return props.node.database || props.node.label;
}
function schemaCommentFromResult(result: { columns?: string[]; rows?: unknown[] }): string {
function resultColumnValue(result: { columns?: string[]; rows?: unknown[] }, names: string[]): string {
const firstRow = result.rows?.[0];
if (Array.isArray(firstRow)) {
const index = Math.max(0, result.columns?.findIndex((column) => column === "comment") ?? 0);
const lowerNames = names.map((name) => name.toLowerCase());
const index = Math.max(0, result.columns?.findIndex((column) => lowerNames.includes(column.toLowerCase())) ?? 0);
return firstRow[index] == null ? "" : String(firstRow[index]);
}
if (firstRow && typeof firstRow === "object" && "comment" in firstRow) {
const value = (firstRow as { comment?: unknown }).comment;
if (firstRow && typeof firstRow === "object") {
const record = firstRow as Record<string, unknown>;
const key = Object.keys(record).find((column) => names.some((name) => name.toLowerCase() === column.toLowerCase()));
const value = key ? record[key] : undefined;
return value == null ? "" : String(value);
}
return "";
}
function databasePropertyEditOptions() {
if (!canEditDatabaseProperties.value) return null;
const base = {
databaseType: currentDatabaseType(),
driverProfile: props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.driver_profile : undefined,
target: "database" as const,
name: databasePropertyName(),
};
if (canEditDatabaseCharsetCollation.value) {
return {
...base,
charset: editDatabaseCharset.value,
collation: editDatabaseCollation.value,
};
}
if (canEditDatabaseComment.value) {
return {
...base,
comment: editDatabaseCommentText.value,
};
}
return null;
}
async function refreshEditDatabasePropertiesPreviewSql() {
editDatabasePropertiesPreviewSql.value = "";
const options = databasePropertyEditOptions();
if (!options) return;
editDatabasePropertiesPreviewSql.value = await buildUpdateDatabasePropertiesSql(options).catch(() => "");
}
async function loadDatabaseCharsetProperties() {
const node = props.node;
if (!node.connectionId) return;
const sql = `SELECT DEFAULT_CHARACTER_SET_NAME AS charset, DEFAULT_COLLATION_NAME AS collation FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${databasePropertyName().replace(/'/g, "''")}';`;
const result = await api.executeQuery(node.connectionId, databasePropertyName(), sql, undefined, undefined, { maxRows: 1 });
const charset = resultColumnValue(result, ["charset", "DEFAULT_CHARACTER_SET_NAME"]);
const collation = resultColumnValue(result, ["collation", "DEFAULT_COLLATION_NAME"]);
if (charset) editDatabaseCharset.value = charset;
if (collation) editDatabaseCollation.value = collation;
}
async function loadDatabaseCommentProperty() {
const node = props.node;
if (!node.connectionId) return;
const sql = buildGetDatabaseCommentSql({
databaseType: currentDatabaseType(),
name: databasePropertyName(),
});
const result = await api.executeQuery(node.connectionId, databasePropertyName(), sql, undefined, undefined, { maxRows: 1 });
editDatabaseCommentText.value = resultColumnValue(result, ["comment"]);
}
async function openEditDatabasePropertiesDialog() {
const node = props.node;
if (!canEditDatabaseProperties.value || !node.connectionId) return;
editDatabasePropertiesLoading.value = true;
editDatabaseCharset.value = "utf8mb4";
editDatabaseCollation.value = "utf8mb4_unicode_ci";
editDatabaseCommentText.value = "";
editDatabasePropertiesPreviewSql.value = "";
createDatabaseCharsetOptions.value = fallbackCreateDatabaseCharset.charsets;
createDatabaseCollationsByCharset.value = fallbackCreateDatabaseCharset.collationsByCharset;
showEditDatabasePropertiesDialog.value = true;
try {
await connectionStore.ensureConnected(node.connectionId);
if (canEditDatabaseCharsetCollation.value) {
await loadCreateDatabaseCharsetMetadata("edit");
await loadDatabaseCharsetProperties().catch(() => undefined);
} else if (canEditDatabaseComment.value) {
await loadDatabaseCommentProperty().catch(() => undefined);
}
await refreshEditDatabasePropertiesPreviewSql();
} finally {
editDatabasePropertiesLoading.value = false;
}
}
async function confirmEditDatabaseProperties() {
const node = props.node;
if (!canEditDatabaseProperties.value || !node.connectionId || editDatabasePropertiesLoading.value) return;
editDatabasePropertiesLoading.value = true;
try {
await connectionStore.ensureConnected(node.connectionId);
const options = databasePropertyEditOptions();
if (!options) return;
const sql = await buildUpdateDatabasePropertiesSql(options);
await api.executeQuery(node.connectionId, databasePropertyName(), sql);
toast(t("contextMenu.editDatabasePropertiesSuccess", { name: node.label }), 3000);
showEditDatabasePropertiesDialog.value = false;
await connectionStore.loadDatabases(node.connectionId, { force: true });
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
} finally {
editDatabasePropertiesLoading.value = false;
}
}
watch([editDatabaseCharset, editDatabaseCollation, editDatabaseCommentText], () => {
if (showEditDatabasePropertiesDialog.value) void refreshEditDatabasePropertiesPreviewSql();
});
const schemaCommentPreviewSql = ref("");
async function refreshSchemaCommentPreviewSql() {
if (!canEditSchemaComment.value) {
schemaCommentPreviewSql.value = "";
return;
}
schemaCommentPreviewSql.value = await buildUpdateDatabasePropertiesSql({
databaseType: currentDatabaseType(),
target: "schema",
name: props.node.schema || props.node.label,
comment: schemaCommentText.value,
}).catch(() => "");
}
watch(schemaCommentText, () => {
if (showEditSchemaCommentDialog.value) void refreshSchemaCommentPreviewSql();
});
function schemaCommentFromResult(result: { columns?: string[]; rows?: unknown[] }): string {
return resultColumnValue(result, ["comment"]);
}
async function openEditSchemaCommentDialog() {
const node = props.node;
if (!canEditSchemaComment.value || !node.connectionId || !node.database) return;
@@ -2550,8 +2705,10 @@ async function openEditSchemaCommentDialog() {
});
const result = await api.executeQuery(node.connectionId, node.database, sql, node.schema, undefined, { maxRows: 1 });
schemaCommentText.value = schemaCommentFromResult(result);
await refreshSchemaCommentPreviewSql();
} catch {
schemaCommentText.value = "";
await refreshSchemaCommentPreviewSql();
} finally {
schemaCommentLoading.value = false;
}
@@ -2563,8 +2720,9 @@ async function confirmEditSchemaComment() {
schemaCommentLoading.value = true;
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = buildSetSchemaCommentSql({
const sql = await buildUpdateDatabasePropertiesSql({
databaseType: currentDatabaseType(),
target: "schema",
name: node.schema || node.label,
comment: schemaCommentText.value,
});
@@ -2594,7 +2752,9 @@ function openCreateDatabaseDialog() {
createDatabaseCharsetOptions.value = fallbackCreateDatabaseCharset.charsets;
createDatabaseCollationsByCharset.value = fallbackCreateDatabaseCharset.collationsByCharset;
showCreateDatabaseDialog.value = true;
void loadCreateDatabaseCharsetMetadata();
if (canSetCreateDatabaseCharset.value) {
void loadCreateDatabaseCharsetMetadata();
}
}
function updateCreateDatabaseCharset(value: string) {
@@ -2603,21 +2763,33 @@ function updateCreateDatabaseCharset(value: string) {
createDatabaseCollation.value = nextCreateDatabaseCollation(value, previousCharset, createDatabaseCollation.value, createDatabaseCollationsByCharset.value);
}
async function loadCreateDatabaseCharsetMetadata() {
function updateEditDatabaseCharset(value: string) {
const previousCharset = editDatabaseCharset.value;
editDatabaseCharset.value = value;
editDatabaseCollation.value = nextCreateDatabaseCollation(value, previousCharset, editDatabaseCollation.value, createDatabaseCollationsByCharset.value);
}
async function loadCreateDatabaseCharsetMetadata(target: "create" | "edit" = "create") {
const node = props.node;
if (!node.connectionId || createDatabaseCharsetLoading.value) return;
createDatabaseCharsetLoading.value = true;
try {
await connectionStore.ensureConnected(node.connectionId);
const [charsetResult, collationResult] = await Promise.all([api.executeQuery(node.connectionId, "", "SHOW CHARACTER SET", undefined, undefined, { maxRows: 1000 }), api.executeQuery(node.connectionId, "", "SHOW COLLATION", undefined, undefined, { maxRows: 10000 })]);
if (!showCreateDatabaseDialog.value) return;
if (target === "create" && !showCreateDatabaseDialog.value) return;
if (target === "edit" && !showEditDatabasePropertiesDialog.value) return;
const metadata = parseCreateDatabaseCharsetMetadata(charsetResult, collationResult);
createDatabaseCharsetOptions.value = metadata.charsets;
createDatabaseCollationsByCharset.value = metadata.collationsByCharset;
if (!createDatabaseCharsetOptions.value.includes(createDatabaseCharset.value) && createDatabaseCharsetOptions.value.length) {
updateCreateDatabaseCharset(createDatabaseCharsetOptions.value[0]);
const selectedCharset = target === "create" ? createDatabaseCharset.value : editDatabaseCharset.value;
if (!createDatabaseCharsetOptions.value.includes(selectedCharset) && createDatabaseCharsetOptions.value.length) {
target === "create" ? updateCreateDatabaseCharset(createDatabaseCharsetOptions.value[0]) : updateEditDatabaseCharset(createDatabaseCharsetOptions.value[0]);
} else {
createDatabaseCollation.value = nextCreateDatabaseCollation(createDatabaseCharset.value, createDatabaseCharset.value, createDatabaseCollation.value, createDatabaseCollationsByCharset.value);
if (target === "create") {
createDatabaseCollation.value = nextCreateDatabaseCollation(createDatabaseCharset.value, createDatabaseCharset.value, createDatabaseCollation.value, createDatabaseCollationsByCharset.value);
} else {
editDatabaseCollation.value = nextCreateDatabaseCollation(editDatabaseCharset.value, editDatabaseCharset.value, editDatabaseCollation.value, createDatabaseCollationsByCharset.value);
}
}
} catch {
createDatabaseCharsetOptions.value = fallbackCreateDatabaseCharset.charsets;
@@ -2745,6 +2917,7 @@ async function confirmCreateDatabase() {
const sql = await buildCreateDatabaseSql({
databaseType: config?.db_type,
driverProfile: config?.driver_profile,
target: "database",
name,
charset: createDatabaseCharset.value,
collation: createDatabaseCollation.value,
@@ -4315,6 +4488,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push({ label: t("contextMenu.clearDefaultDatabase"), action: clearNodeDefaultDatabase, icon: Database });
}
}
if (canEditDatabaseProperties.value) {
items.push({ label: t("contextMenu.editDatabaseProperties"), action: openEditDatabasePropertiesDialog, icon: SquarePen });
}
if (canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
}
@@ -5112,6 +5288,79 @@ function treeItemMenuItems(): ContextMenuItem[] {
</DialogContent>
</Dialog>
<Dialog v-model:open="showEditDatabasePropertiesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.editDatabasePropertiesTitle", { name: node.label }) }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div v-if="canEditDatabaseCharsetCollation" class="grid gap-3">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.createDatabaseCharset") }}</label>
<SearchableSelect
:model-value="editDatabaseCharset"
:options="createDatabaseCharsetOptions"
:placeholder="t('contextMenu.createDatabaseCharsetPlaceholder')"
:search-placeholder="t('contextMenu.createDatabaseCharsetSearchPlaceholder')"
:empty-text="t('contextMenu.createDatabaseCharsetEmpty')"
:loading-text="t('contextMenu.createDatabaseCharsetLoading')"
:loading="createDatabaseCharsetLoading"
:normalize-custom="normalizeCreateDatabaseCharset"
allow-custom
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between border bg-background px-3 text-sm shadow-xs hover:bg-accent"
content-class="w-[var(--reka-popover-trigger-width)]"
@update:model-value="updateEditDatabaseCharset"
>
<template #custom-option-label="{ value }">
<span class="truncate">{{ t("contextMenu.createDatabaseCharsetCustomOption", { value }) }}</span>
</template>
</SearchableSelect>
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.createDatabaseCollation") }}</label>
<SearchableSelect
v-model="editDatabaseCollation"
:options="createDatabaseCollationOptionsForCharset(editDatabaseCharset, createDatabaseCollationsByCharset)"
:placeholder="t('contextMenu.createDatabaseCollationPlaceholder')"
:search-placeholder="t('contextMenu.createDatabaseCollationSearchPlaceholder')"
:empty-text="t('contextMenu.createDatabaseCollationEmpty')"
:loading-text="t('contextMenu.createDatabaseCollationLoading')"
:loading="createDatabaseCharsetLoading"
:normalize-custom="normalizeCreateDatabaseCharset"
allow-custom
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between border bg-background px-3 text-sm shadow-xs hover:bg-accent"
content-class="w-[var(--reka-popover-trigger-width)]"
>
<template #custom-option-label="{ value }">
<span class="truncate">{{ t("contextMenu.createDatabaseCollationCustomOption", { value }) }}</span>
</template>
</SearchableSelect>
</div>
</div>
<div v-if="canEditDatabaseComment" class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.editDatabaseComment") }}</label>
<textarea
v-model="editDatabaseCommentText"
class="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/40"
:placeholder="t('contextMenu.editDatabaseCommentPlaceholder')"
:disabled="editDatabasePropertiesLoading"
@keydown.meta.enter.prevent="confirmEditDatabaseProperties"
@keydown.ctrl.enter.prevent="confirmEditDatabaseProperties"
></textarea>
</div>
<pre v-if="editDatabasePropertiesPreviewSql" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(editDatabasePropertiesPreviewSql)"></pre>
</div>
<DialogFooter>
<Button variant="outline" :disabled="editDatabasePropertiesLoading" @click="showEditDatabasePropertiesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="editDatabasePropertiesLoading" @click="confirmEditDatabaseProperties">
{{ editDatabasePropertiesLoading ? t("contextMenu.editDatabasePropertiesSaving") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateNacosNamespaceDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>