fix(structure): initialize dialogs and edit clickhouse columns

This commit is contained in:
t8y2 2026-05-19 13:31:42 +08:00
parent 5b3b69f7fb
commit 69ee7f2d8b
18 changed files with 325 additions and 120 deletions

View File

@ -28,13 +28,17 @@ const passphrase = ref("");
const passphraseConfirm = ref("");
const error = ref("");
watch(dialogOpen, (open) => {
if (open) {
passphrase.value = "";
passphraseConfirm.value = "";
error.value = "";
}
});
watch(
dialogOpen,
(open) => {
if (open) {
passphrase.value = "";
passphraseConfirm.value = "";
error.value = "";
}
},
{ immediate: true },
);
function confirm() {
if (!passphrase.value) {

View File

@ -667,17 +667,21 @@ function resetForm() {
resetTestState();
}
watch(open, (value) => {
if (!value) {
resetForm();
return;
}
if (!props.editConfig) {
resetForm();
}
void loadJdbcDrivers();
void loadAgentDrivers();
});
watch(
open,
(value) => {
if (!value) {
resetForm();
return;
}
if (!props.editConfig) {
resetForm();
}
void loadJdbcDrivers();
void loadAgentDrivers();
},
{ immediate: true },
);
watch(canUseSsh, (value) => {
if (!value && configTab.value === "ssh") {

View File

@ -661,9 +661,13 @@ function stopDrag() {
window.removeEventListener("mouseup", stopDrag);
}
watch(open, (value) => {
if (value) void initialize();
});
watch(
open,
(value) => {
if (value) void initialize();
},
{ immediate: true },
);
watch(
() => visibleTables.value.map((table) => table.name).join("\n"),

View File

@ -412,21 +412,25 @@ watch(sourceTable, (table, previous) => {
}
});
watch(targetTable, () => clearResult());
watch(open, async (value) => {
if (!value) return;
result.value = null;
syncSql.value = "";
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
if (props.prefillDatabase) await loadSchemas("source", props.prefillSchema);
if (props.prefillTable) {
await loadTables("source");
if (sourceTables.value.includes(props.prefillTable)) sourceTable.value = props.prefillTable;
watch(
open,
async (value) => {
if (!value) return;
result.value = null;
syncSql.value = "";
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
if (props.prefillDatabase) await loadSchemas("source", props.prefillSchema);
if (props.prefillTable) {
await loadTables("source");
if (sourceTables.value.includes(props.prefillTable)) sourceTable.value = props.prefillTable;
}
}
}
});
},
{ immediate: true },
);
</script>
<template>

View File

@ -356,23 +356,27 @@ watch(targetDatabase, (database) => {
watch(sourceSchema, () => resetResult());
watch(targetSchema, () => resetResult());
watch(open, async (val) => {
if (val) {
step.value = "select";
diffs.value = [];
syncErrors.value = [];
executedCount.value = 0;
executeTotal.value = 0;
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) {
sourceDatabase.value = props.prefillDatabase;
await loadSchemas("source", props.prefillSchema);
watch(
open,
async (val) => {
if (val) {
step.value = "select";
diffs.value = [];
syncErrors.value = [];
executedCount.value = 0;
executeTotal.value = 0;
if (props.prefillConnectionId) {
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) {
sourceDatabase.value = props.prefillDatabase;
await loadSchemas("source", props.prefillSchema);
}
}
}
}
});
},
{ immediate: true },
);
</script>
<template>

View File

@ -292,21 +292,25 @@ watch(schema, (value) => {
if (value) loadTables(preferredTable).catch((e) => toast(String(e), 5000));
});
watch(open, async (val) => {
if (val) {
resetState();
pendingPrefillTable.value = props.prefillTable ?? "";
if (props.prefillConnectionId) {
skipConnectionWatch.value = true;
connectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId);
if (props.prefillDatabase) {
database.value = props.prefillDatabase;
await loadSchemas(props.prefillSchema);
watch(
open,
async (val) => {
if (val) {
resetState();
pendingPrefillTable.value = props.prefillTable ?? "";
if (props.prefillConnectionId) {
skipConnectionWatch.value = true;
connectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId);
if (props.prefillDatabase) {
database.value = props.prefillDatabase;
await loadSchemas(props.prefillSchema);
}
}
}
}
});
},
{ immediate: true },
);
</script>
<template>

View File

@ -231,12 +231,16 @@ async function cancelImport() {
await api.cancelTableImport(importId.value);
}
watch(open, (value) => {
if (value) {
resetState();
void loadTargetColumns();
}
});
watch(
open,
(value) => {
if (value) {
resetState();
void loadTargetColumns();
}
},
{ immediate: true },
);
</script>
<template>

View File

@ -119,15 +119,19 @@ const filteredItems = computed(() => {
const filteredCounts = computed(() => summarizeLineageCounts(filteredItems.value));
watch(dialogOpen, (open) => {
if (open) {
confidenceFilter.value = "all";
searchText.value = "";
void loadLineage();
} else {
cancelLoad();
}
});
watch(
dialogOpen,
(open) => {
if (open) {
confidenceFilter.value = "all";
searchText.value = "";
void loadLineage();
} else {
cancelLoad();
}
},
{ immediate: true },
);
function cancelLoad() {
cancelled.value = true;

View File

@ -95,13 +95,17 @@ const progressLabel = computed(() =>
t("databaseSearch.progress", { done: progressDone.value, total: progressTotal.value }),
);
watch(dialogOpen, (open) => {
if (open) {
resetSearchState();
} else {
stopSearch();
}
});
watch(
dialogOpen,
(open) => {
if (open) {
resetSearchState();
} else {
stopSearch();
}
},
{ immediate: true },
);
function resetSearchState() {
running.value = false;

View File

@ -376,13 +376,17 @@ watch(sqlConnections, () => {
connectionId.value = resolveInitialConnectionId();
});
watch(open, (value) => {
if (!value) return;
resetState();
if (connectionId.value) {
loadDatabasesForConnection(connectionId.value);
}
});
watch(
open,
(value) => {
if (!value) return;
resetState();
if (connectionId.value) {
loadDatabasesForConnection(connectionId.value);
}
},
{ immediate: true },
);
</script>
<template>

View File

@ -159,19 +159,23 @@ watch(targetConnectionId, (id) => {
loadDatabases(id, "target");
});
watch(open, async (val) => {
if (val) {
resetState();
if (props.prefillConnectionId) {
skipSourceWatch.value = true;
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) {
sourceDatabase.value = props.prefillDatabase;
watch(
open,
async (val) => {
if (val) {
resetState();
if (props.prefillConnectionId) {
skipSourceWatch.value = true;
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (props.prefillDatabase) {
sourceDatabase.value = props.prefillDatabase;
}
}
}
}
});
},
{ immediate: true },
);
function resetState() {
sourceConnectionId.value = "";

View File

@ -167,6 +167,12 @@ const clickhouseCapabilities = capabilities({
createTable: true,
addColumn: true,
dropColumn: true,
renameColumn: true,
alterExistingColumn: true,
alterType: true,
alterNullability: true,
alterDefault: true,
comment: true,
});
const capabilityByType: Partial<Record<DatabaseType, TableStructureCapabilities>> = {

View File

@ -85,9 +85,23 @@ function normalizeDefault(value: string | null | undefined): string {
return trimmed.toLowerCase() === "null" ? "" : trimmed;
}
function unwrapClickHouseNullableType(dataType: string): string {
const match = dataType.trim().match(/^Nullable\s*\((.*)\)$/is);
return match ? match[1].trim() : dataType.trim();
}
function clickHouseColumnType(column: EditableStructureColumn): string {
const dataType = column.dataType.trim();
if (column.isNullable) {
return /^Nullable\s*\(/i.test(dataType) ? dataType : `Nullable(${dataType})`;
}
return unwrapClickHouseNullableType(dataType);
}
function columnDefinition(databaseType: StructureSqlFlavor, column: EditableStructureColumn): string {
const parts = [quoteIdent(databaseType, column.name), column.dataType.trim()];
if (!column.isNullable && !isOracleLike(databaseType)) parts.push("NOT NULL");
const dataType = databaseType === "clickhouse" ? clickHouseColumnType(column) : column.dataType.trim();
const parts = [quoteIdent(databaseType, column.name), dataType];
if (!column.isNullable && !isOracleLike(databaseType) && databaseType !== "clickhouse") parts.push("NOT NULL");
const defaultValue = normalizeDefault(column.defaultValue);
if (defaultValue) parts.push(`DEFAULT ${defaultValue}`);
if (databaseType === "mysql" && clean(column.comment)) {
@ -127,6 +141,11 @@ function buildAddColumnSql(databaseType: StructureSqlFlavor, table: string, colu
`COMMENT ON COLUMN ${table}.${quoteIdent(databaseType, column.name)} IS ${quoteString(clean(column.comment))};`,
);
}
if (databaseType === "clickhouse" && clean(column.comment)) {
statements.push(
`ALTER TABLE ${table} COMMENT COLUMN ${quoteIdent(databaseType, column.name)} ${quoteString(clean(column.comment))};`,
);
}
return statements;
}
@ -240,6 +259,48 @@ function buildH2ExistingColumnSql(table: string, column: EditableStructureColumn
return statements;
}
function buildClickHouseExistingColumnSql(table: string, column: EditableStructureColumn): string[] {
const original = column.original;
if (!original) return [];
const statements: string[] = [];
let currentName = original.name;
if (column.name !== original.name) {
statements.push(
`ALTER TABLE ${table} RENAME COLUMN ${quoteIdent("clickhouse", original.name)} TO ${quoteIdent("clickhouse", column.name)};`,
);
currentName = column.name;
}
if (
clickHouseColumnType(column) !== original.data_type.trim() ||
normalizeDefault(column.defaultValue) !== originalDefault(column)
) {
const defaultValue = normalizeDefault(column.defaultValue);
if (defaultValue) {
statements.push(
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)} DEFAULT ${defaultValue};`,
);
} else if (originalDefault(column)) {
statements.push(`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} REMOVE DEFAULT;`);
if (clickHouseColumnType(column) !== original.data_type.trim()) {
statements.push(
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)};`,
);
}
} else {
statements.push(
`ALTER TABLE ${table} MODIFY COLUMN ${quoteIdent("clickhouse", currentName)} ${clickHouseColumnType(column)};`,
);
}
}
if (clean(column.comment) !== originalComment(column)) {
statements.push(
`ALTER TABLE ${table} COMMENT COLUMN ${quoteIdent("clickhouse", currentName)} ${quoteString(clean(column.comment))};`,
);
}
return statements;
}
function buildSqliteExistingColumnSql(table: string, column: EditableStructureColumn, warnings: string[]): string[] {
const original = column.original;
if (!original) return [];
@ -321,6 +382,8 @@ function buildColumnSql(options: BuildTableStructureChangeSqlOptions, warnings:
statements.push(...buildOracleLikeExistingColumnSql(dialect, table, column));
} else if (dialect === "h2") {
statements.push(...buildH2ExistingColumnSql(table, column));
} else if (dialect === "clickhouse") {
statements.push(...buildClickHouseExistingColumnSql(table, column));
} else if (dialect === "sqlite") {
statements.push(...buildSqliteExistingColumnSql(table, column, warnings));
} else {
@ -463,8 +526,9 @@ export function buildCreateTableSql(options: BuildTableStructureChangeSqlOptions
const pkColumns = activeColumns.filter((c) => c.isPrimaryKey);
const colDefs = activeColumns.map((col) => {
const parts = [quoteIdent(dialect, col.name), col.dataType.trim()];
if (!col.isNullable && !col.isPrimaryKey) parts.push("NOT NULL");
const dataType = dialect === "clickhouse" ? clickHouseColumnType(col) : col.dataType.trim();
const parts = [quoteIdent(dialect, col.name), dataType];
if (!col.isNullable && !col.isPrimaryKey && dialect !== "clickhouse") parts.push("NOT NULL");
const defaultValue = normalizeDefault(col.defaultValue);
if (defaultValue) parts.push(`DEFAULT ${defaultValue}`);
if (dialect === "mysql" && capabilities.comment && clean(col.comment)) {
@ -489,6 +553,15 @@ export function buildCreateTableSql(options: BuildTableStructureChangeSqlOptions
}
}
}
if (capabilities.comment && dialect === "clickhouse") {
for (const col of activeColumns) {
if (clean(col.comment)) {
statements.push(
`ALTER TABLE ${table} COMMENT COLUMN ${quoteIdent(dialect, col.name)} ${quoteString(clean(col.comment))};`,
);
}
}
}
for (const index of options.indexes.filter((idx) => !idx.markedForDrop && !idx.isPrimary)) {
const name = clean(index.name);

View File

@ -160,7 +160,7 @@ pub async fn list_tables(client: &ChClient, database: &str) -> Result<Vec<TableI
pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let sql = format!(
"SELECT name, type, default_kind, default_expression, is_in_primary_key \
"SELECT name, type, default_kind, default_expression, is_in_primary_key, comment \
FROM system.columns WHERE database = '{}' AND table = '{}' ORDER BY position",
database.replace('\'', "\\'"),
table.replace('\'', "\\'")
@ -183,7 +183,7 @@ pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Resu
column_default,
is_primary_key: is_pk,
extra: None,
comment: None,
comment: row.get(5).and_then(|v| v.as_str()).filter(|value| !value.is_empty()).map(str::to_string),
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,

View File

@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import test from "node:test";
const mountedOpenDialogs = [
"apps/desktop/src/components/connection/ConnectionDialog.vue",
"apps/desktop/src/components/transfer/DataTransferDialog.vue",
"apps/desktop/src/components/diff/SchemaDiffDialog.vue",
"apps/desktop/src/components/diff/DataCompareDialog.vue",
"apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue",
"apps/desktop/src/components/diagram/SchemaDiagramDialog.vue",
"apps/desktop/src/components/import/TableImportDialog.vue",
"apps/desktop/src/components/structure/TableStructureEditorDialog.vue",
"apps/desktop/src/components/lineage/FieldLineageDialog.vue",
"apps/desktop/src/components/search/DatabaseSearchDialog.vue",
"apps/desktop/src/components/export/DatabaseExportDialog.vue",
"apps/desktop/src/components/config/ConfigPassphraseDialog.vue",
] as const;
test("dialogs initialized through v-if run their open watcher on mount", () => {
for (const filePath of mountedOpenDialogs) {
const source = readFileSync(filePath, "utf8");
assert.match(
source,
/watch\(\s*(open|dialogOpen),[\s\S]*?\{\s*immediate:\s*true\s*\},?\s*\)/,
`${filePath} should use an immediate open watcher`,
);
}
});

View File

@ -59,9 +59,12 @@ test("limited analytic engines can open the editor for supported operations only
assert.equal(clickhouse.createTable, true);
assert.equal(clickhouse.addColumn, true);
assert.equal(clickhouse.dropColumn, true);
assert.equal(clickhouse.renameColumn, false);
assert.equal(clickhouse.alterExistingColumn, false);
assert.equal(clickhouse.comment, false);
assert.equal(clickhouse.renameColumn, true);
assert.equal(clickhouse.alterExistingColumn, true);
assert.equal(clickhouse.alterType, true);
assert.equal(clickhouse.alterNullability, true);
assert.equal(clickhouse.alterDefault, true);
assert.equal(clickhouse.comment, true);
assert.equal(clickhouse.createIndex, false);
assert.equal(canEditTableStructure("clickhouse"), true);
});

View File

@ -3,6 +3,7 @@ import { strict as assert } from "node:assert";
import test from "node:test";
const source = readFileSync("apps/desktop/src/components/structure/TableStructureEditorDialog.vue", "utf8");
const clickhouseSource = readFileSync("crates/dbx-core/src/db/clickhouse_driver.rs", "utf8");
test("column comments can be expanded into a multiline editor", () => {
assert.match(source, /PopoverContent/);
@ -18,6 +19,11 @@ test("structure editor keeps columns when optional metadata fails", () => {
assert.match(source, /api\s*\n\s*\.listTriggers[\s\S]*\.catch\(\(\) => \[\]\)/);
});
test("ClickHouse column metadata preserves comments for structure editing", () => {
assert.match(clickhouseSource, /SELECT name, type, default_kind, default_expression, is_in_primary_key, comment/);
assert.match(clickhouseSource, /comment:\s*row\.get\(5\)/);
});
test("structure editor loads immediately when mounted open", () => {
assert.match(source, /watch\(\s*open,[\s\S]*\{\s*immediate:\s*true\s*\},?\s*\)/);
});

View File

@ -657,12 +657,12 @@ test("Redshift skips unsupported index operations while keeping column DDL", ()
assert.deepEqual(result.warnings, ['Creating indexes is not supported for redshift from this editor.']);
});
test("builds ClickHouse limited column DDL and skips indexes", () => {
test("builds ClickHouse column DDL and skips indexes", () => {
const result = buildTableStructureChangeSql({
databaseType: "clickhouse",
tableName: "events",
columns: [
column({ id: "new", name: "name", dataType: "String", isNullable: true }),
column({ id: "new", name: "name", dataType: "String", isNullable: false }),
column({
id: "legacy",
name: "legacy",
@ -679,12 +679,14 @@ test("builds ClickHouse limited column DDL and skips indexes", () => {
column({
id: "kind",
name: "event_kind",
dataType: "String",
dataType: "LowCardinality(String)",
isNullable: false,
defaultValue: "'view'",
original: {
name: "kind",
data_type: "String",
is_nullable: true,
column_default: null,
is_nullable: false,
column_default: "'click'",
is_primary_key: false,
extra: null,
},
@ -696,10 +698,52 @@ test("builds ClickHouse limited column DDL and skips indexes", () => {
assert.deepEqual(result.statements, [
'ALTER TABLE "events" ADD COLUMN "name" String;',
'ALTER TABLE "events" DROP COLUMN "legacy";',
'ALTER TABLE "events" RENAME COLUMN "kind" TO "event_kind";',
'ALTER TABLE "events" MODIFY COLUMN "event_kind" LowCardinality(String) DEFAULT \'view\';',
]);
assert.deepEqual(result.warnings, [
'Renaming columns is not supported for clickhouse from this editor.',
'Creating indexes is not supported for clickhouse from this editor.',
assert.deepEqual(result.warnings, ['Creating indexes is not supported for clickhouse from this editor.']);
});
test("builds ClickHouse nullable and comment column changes", () => {
const result = buildTableStructureChangeSql({
databaseType: "clickhouse",
tableName: "events",
columns: [
column({
id: "new",
name: "source",
dataType: "String",
isNullable: true,
comment: "traffic source",
}),
column({
id: "status",
name: "status",
dataType: "Nullable(String)",
isNullable: false,
defaultValue: "",
comment: "current status",
original: {
name: "status",
data_type: "Nullable(String)",
is_nullable: true,
column_default: "'pending'",
is_primary_key: false,
extra: null,
comment: "old status",
},
}),
],
indexes: [],
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(result.statements, [
'ALTER TABLE "events" ADD COLUMN "source" Nullable(String);',
'ALTER TABLE "events" COMMENT COLUMN "source" \'traffic source\';',
'ALTER TABLE "events" MODIFY COLUMN "status" REMOVE DEFAULT;',
'ALTER TABLE "events" MODIFY COLUMN "status" String;',
'ALTER TABLE "events" COMMENT COLUMN "status" \'current status\';',
]);
});