From e429c8945bb25014eec7071412c0bb83a3f9fcf1 Mon Sep 17 00:00:00 2001 From: DengQingNian <117324885+DengQingNian@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:13:57 +0800 Subject: [PATCH] feat(mysql): edit column charset and collation --- .../structure/TableStructureEditor.vue | 129 +++++++++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + .../table/tableStructureEditorState.spec.ts | 26 +- .../src/lib/table/tableColumnTemplates.ts | 2 + .../src/lib/table/tableStructureEditorSql.ts | 2 + .../lib/table/tableStructureEditorState.ts | 10 + apps/desktop/src/types/database.ts | 2 + crates/dbx-core/src/db/clickhouse_driver.rs | 1 + .../dbx-core/src/db/elasticsearch_driver.rs | 1 + crates/dbx-core/src/db/influxdb_driver.rs | 6 + crates/dbx-core/src/db/mysql.rs | 15 ++ crates/dbx-core/src/db/ob_oracle.rs | 1 + crates/dbx-core/src/db/postgres.rs | 1 + crates/dbx-core/src/db/questdb.rs | 1 + crates/dbx-core/src/db/rqlite_driver.rs | 1 + crates/dbx-core/src/db/sqlite.rs | 1 + crates/dbx-core/src/db/sqlserver.rs | 2 + crates/dbx-core/src/db/turso_driver.rs | 1 + crates/dbx-core/src/schema.rs | 7 + crates/dbx-core/src/schema/duckdb_metadata.rs | 1 + crates/dbx-core/src/schema/normalization.rs | 1 + crates/dbx-core/src/schema_diff.rs | 2 + .../src/table_structure_sql/column_alter.rs | 10 +- .../src/table_structure_sql/column_format.rs | 26 ++ .../src/table_structure_sql/columns.rs | 5 +- .../dbx-core/src/table_structure_sql/tests.rs | 223 ++++++++++++++++++ .../dbx-core/src/table_structure_sql/types.rs | 10 +- crates/dbx-core/src/transfer.rs | 8 + crates/dbx-core/src/types.rs | 6 +- .../tests/live_sqlserver_completion.rs | 3 + packages/app-tests/settingsStore.test.ts | 2 +- packages/node-core/src/database.ts | 10 +- 38 files changed, 524 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 66d268f14..0a0dd529e 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -30,7 +30,9 @@ import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import type { TableInfoTab, TableStructureEditorDraft, TableStructureEditorTarget, TableStructureEditorViewport } from "@/types/database"; import { + applyManticoreDdlColumnExtras, buildStructureTargetLabel, + canEditManticoreColumnProperties, combineDataTypeForDatabase, createColumnDrafts, createForeignKeyDrafts, @@ -43,15 +45,16 @@ import { getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, - isSqlServerIdentityCompatibleDataType, + isMysqlCharacterDataType, isProtectedManticoreIdColumn, + isSqlServerIdentityCompatibleDataType, parseExtraToColumnExtra, rehydrateColumnDraftsFromMetadata, splitDataType, toColumnNames, - applyManticoreDdlColumnExtras, - canEditManticoreColumnProperties, } from "@/lib/table/tableStructureEditorState"; +import { CREATE_DATABASE_CHARSET_OPTIONS, createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, normalizeCreateDatabaseCharsetKey, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; +import type { CreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; import * as api from "@/lib/backend/api"; const { t } = useI18n(); @@ -174,6 +177,8 @@ function columnChanged(column: EditableStructureColumn, index: number): boolean !sameText(column.defaultValue, original.column_default) || !sameText(column.comment, original.comment) || column.isPrimaryKey !== original.is_primary_key || + !sameText(column.characterSet, original.character_set) || + !sameText(column.collation, original.collation) || JSON.stringify(column.extra) !== JSON.stringify(parseExtraToColumnExtra(original.extra, databaseType.value)) ); } @@ -232,7 +237,7 @@ const structureDensityValues: StructureEditorDensity[] = ["compact", "standard", const STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-column-widths"; const STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-index-column-widths"; const STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY = "dbx-structure-editor-sql-preview-collapsed"; -const STRUCTURE_COLUMN_WIDTH_COUNT = 10; +const STRUCTURE_COLUMN_WIDTH_COUNT = 12; const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 8; const PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS = new Set([0, 1, 6]); const structureDensityMetrics: Record< @@ -256,7 +261,7 @@ const structureDensityMetrics: Record< } > = { compact: { - columns: [28, 168, 136, 82, 60, 52, 108, 220, 144, 108], + columns: [28, 168, 136, 82, 60, 52, 108, 220, 80, 120, 144, 108], indexes: [120, 180, 60, 88, 124, 144, 120, 70], minColumnWidth: 24, minIndexColumnWidth: 48, @@ -273,7 +278,7 @@ const structureDensityMetrics: Record< lineHeight: 1.35, }, standard: { - columns: [32, 200, 160, 104, 72, 64, 128, 260, 160, 136], + columns: [32, 200, 160, 104, 72, 64, 128, 260, 90, 140, 160, 136], indexes: [148, 224, 72, 108, 148, 180, 148, 84], minColumnWidth: 28, minIndexColumnWidth: 60, @@ -290,7 +295,7 @@ const structureDensityMetrics: Record< lineHeight: 1.4, }, comfortable: { - columns: [36, 232, 188, 116, 84, 76, 152, 300, 188, 148], + columns: [36, 232, 188, 116, 84, 76, 152, 300, 100, 160, 188, 148], indexes: [176, 260, 84, 124, 176, 216, 176, 104], minColumnWidth: 32, minIndexColumnWidth: 64, @@ -317,10 +322,17 @@ function metricsForDensity(density: StructureEditorDensity) { } function normalizeStructureColumnWidths(value: unknown, density: StructureEditorDensity): number[] | null { - if (!Array.isArray(value) || value.length !== STRUCTURE_COLUMN_WIDTH_COUNT) return null; - const minWidth = metricsForDensity(density).minColumnWidth; - const widths = value.map((item) => Number(item)); + if (!Array.isArray(value)) return null; + let widths = value.map((item) => Number(item)); if (widths.some((item) => !Number.isFinite(item))) return null; + // Backward compatibility: pad old 11-column persisted layout to 12 by inserting + // a default collation width at index 9. + if (widths.length === STRUCTURE_COLUMN_WIDTH_COUNT - 1) { + const defaultWidths = metricsForDensity(density).columns; + widths = [...widths.slice(0, 9), defaultWidths[9], ...widths.slice(9)]; + } + if (widths.length !== STRUCTURE_COLUMN_WIDTH_COUNT) return null; + const minWidth = metricsForDensity(density).minColumnWidth; return widths.map((item) => Math.max(minWidth, item)); } @@ -651,7 +663,56 @@ const showExtendedProperties = computed(() => { const dt = databaseType.value; return dt === "mysql" || dt === "manticoresearch" || isPostgresIdentityType(dt) || dt === "sqlserver"; }); -const extendedPropertiesColumnIndex = 8; +const showCharacterSet = computed(() => structureDialect.value === "mysql"); + +const serverCharsetMetadata = ref(); +const charsetMetadataLoading = ref(false); + +const mysqlCharsetOptions = computed(() => { + const meta = serverCharsetMetadata.value; + return meta ? meta.charsets : ([...CREATE_DATABASE_CHARSET_OPTIONS] as string[]); +}); + +function collationOptionsForCharset(charset: string): string[] { + const meta = serverCharsetMetadata.value; + if (meta) { + return meta.collationsByCharset[normalizeCreateDatabaseCharsetKey(charset)] ?? []; + } + return createDatabaseCollationOptionsForCharset(charset); +} + +async function loadCharsetMetadata() { + if (charsetMetadataLoading.value || !showCharacterSet.value) return; + charsetMetadataLoading.value = true; + try { + await store.ensureConnected(props.connectionId); + const [charsetResult, collationResult] = await Promise.all([api.executeQuery(props.connectionId, props.database, "SHOW CHARACTER SET"), api.executeQuery(props.connectionId, props.database, "SHOW COLLATION")]); + serverCharsetMetadata.value = parseCreateDatabaseCharsetMetadata(charsetResult, collationResult); + } catch { + serverCharsetMetadata.value = fallbackCreateDatabaseCharsetMetadata(); + } finally { + charsetMetadataLoading.value = false; + } +} + +function onCharsetChange(column: EditableStructureColumn, charset: string) { + column.characterSet = charset; + // If the collation is no longer valid for the new charset, clear it so the + // server picks its default (COLLATE is only emitted when explicitly chosen). + if (column.collation && !collationOptionsForCharset(charset).includes(column.collation)) { + column.collation = ""; + } +} + +function columnCharset(column: EditableStructureColumn): string { + return column.characterSet ?? ""; +} + +function columnCollation(column: EditableStructureColumn): string { + return column.collation ?? ""; +} + +const extendedPropertiesColumnIndex = 10; const actionButtonGap = 2; const columnActionButtonCount = computed(() => (canShowColumnDragControls.value ? 2 : 1)); const columnActionsWidth = computed(() => { @@ -677,10 +738,12 @@ const colLabels = computed(() => { if (columnEditorControls.value.primaryKey) labels.push({ key: "primaryKey", label: t("structureEditor.primaryKey"), widthIndex: 5 }); if (columnEditorControls.value.defaultValue) labels.push({ key: "defaultValue", label: t("structureEditor.defaultValue"), widthIndex: 6 }); if (columnEditorControls.value.comment) labels.push({ key: "comment", label: t("structureEditor.comment"), widthIndex: 7 }); + if (showCharacterSet.value) labels.push({ key: "characterSet", label: t("structureEditor.characterSet"), widthIndex: 8 }); + if (showCharacterSet.value) labels.push({ key: "collation", label: t("structureEditor.collation"), widthIndex: 9 }); if (showExtendedProperties.value) { labels.push({ key: "extendedProperties", label: t("structureEditor.extendedProperties"), widthIndex: extendedPropertiesColumnIndex }); } - labels.push({ key: "actions", label: t("structureEditor.actions"), widthIndex: 9 }); + labels.push({ key: "actions", label: t("structureEditor.actions"), widthIndex: 11 }); return labels; }); const indexColLabels = computed(() => [t("structureEditor.indexName"), t("structureEditor.indexColumns"), t("structureEditor.unique"), t("structureEditor.indexType"), t("structureEditor.includedColumns"), t("structureEditor.filter"), t("structureEditor.comment"), t("structureEditor.actions")]); @@ -1117,6 +1180,9 @@ async function loadStructure(silent = false, scope: StructureRefreshScope = FULL /* ignore — Manticore column properties can still come from SHOW COLUMNS when available */ } } + // Load live charset/collation metadata from the MySQL server so the column + // editor shows the correct options for the server version. + void loadCharsetMetadata(); columns.value = createColumnDrafts(nextColumns, databaseType.value); } @@ -1185,6 +1251,8 @@ async function addColumn() { defaultValue: "", comment: "", isPrimaryKey: false, + characterSet: "", + collation: "", extra: {}, markedForDrop: false, }; @@ -1311,6 +1379,11 @@ function updateSqlServerIdentityIncrement(column: EditableStructureColumn, value function updateColumnDataType(column: EditableStructureColumn, baseType: string) { column.dataType = combineDataTypeForDatabase(databaseType.value, baseType, getDefaultLengthForType(databaseType.value, baseType)); syncSqlServerIdentityForDataType(column); + // Clear charset/collation when switching to a non-character MySQL type + if (showCharacterSet.value && !isMysqlCharacterDataType(column.dataType)) { + column.characterSet = ""; + column.collation = ""; + } } function updateColumnDataTypeLength(column: EditableStructureColumn, value: string | number) { @@ -1630,6 +1703,12 @@ function isColumnCommentDisabled(column: EditableStructureColumn): boolean { return column.markedForDrop || !structureCapabilities.value.comment; } +function isColumnCharsetDisabled(column: EditableStructureColumn): boolean { + if (column.markedForDrop) return true; + if (!showCharacterSet.value) return true; + return !isMysqlCharacterDataType(column.dataType); +} + function isPrimaryKeyDisabled(column: EditableStructureColumn): boolean { if (column.markedForDrop) return true; if (!column.original) return false; @@ -2372,6 +2451,32 @@ watch(activeTab, (tab) => { + + + + + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 38ca3a09e..1b16d53a8 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1840,6 +1840,10 @@ export default { defaultValue: "Default", defaultValuePresets: "Default value presets", comment: "Comment", + characterSet: "Charset", + charsetPlaceholder: "Charset", + collation: "Collation", + collationPlaceholder: "Collation", editComment: "Edit comment", commentPlaceholder: "Enter column comment...", tableCommentPlaceholder: "Enter table comment...", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d450bdfae..3f06a7375 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1772,6 +1772,10 @@ export default withEnglishFallback({ defaultValue: "Valor por defecto", defaultValuePresets: "Valores predeterminados preestablecidos", comment: "Comentario", + characterSet: "Conjunto de caracteres", + charsetPlaceholder: "Charset", + collation: "Cotejamiento", + collationPlaceholder: "Cotejamiento", editComment: "Editar comentario", commentPlaceholder: "Ingresar comentario de columna...", tableCommentPlaceholder: "Ingresar comentario de tabla...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b5770500a..4cf443e60 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1770,6 +1770,10 @@ export default withEnglishFallback({ defaultValue: "Valore Predefinito", defaultValuePresets: "Preimpostazioni valore predefinito", comment: "Commento", + characterSet: "Set di caratteri", + charsetPlaceholder: "Charset", + collation: "Regole di confronto", + collationPlaceholder: "Regole di confronto", editComment: "Modifica commento", commentPlaceholder: "Inserisci commento colonna...", tableCommentPlaceholder: "Inserisci commento tabella...", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 86a50447f..3214b3138 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1804,6 +1804,10 @@ export default withEnglishFallback({ defaultValue: "デフォルト", defaultValuePresets: "デフォルト値プリセット", comment: "コメント", + characterSet: "文字セット", + charsetPlaceholder: "文字セット", + collation: "照合順序", + collationPlaceholder: "照合順序", editComment: "コメントを編集", commentPlaceholder: "列コメントを入力...", tableCommentPlaceholder: "テーブルコメントを入力...", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index faf1d87c0..6d84fa2d8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1771,6 +1771,10 @@ export default withEnglishFallback({ defaultValue: "Padrão", defaultValuePresets: "Predefinições de valor padrão", comment: "Comentário", + characterSet: "Conjunto de caracteres", + charsetPlaceholder: "Charset", + collation: "Collation", + collationPlaceholder: "Collation", editComment: "Editar comentário", commentPlaceholder: "Insira o comentário da coluna...", tableCommentPlaceholder: "Insira o comentário da tabela...", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0e9398085..766a70d72 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1839,6 +1839,10 @@ export default withEnglishFallback({ defaultValue: "默认值", defaultValuePresets: "默认值预设", comment: "注释", + characterSet: "字符集", + charsetPlaceholder: "字符集", + collation: "排序规则", + collationPlaceholder: "排序规则", editComment: "编辑注释", commentPlaceholder: "输入字段注释...", tableCommentPlaceholder: "输入表注释...", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a7937a1fa..de28b53e7 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1727,6 +1727,10 @@ export default withEnglishFallback({ identitySeed: "起始值", identityIncrement: "增量", sqlServerIdentityTypeHint: "SQL Server 自動遞增僅支援 tinyint、smallint、int、bigint、decimal/numeric(小數位為 0)", + characterSet: "字元集", + charsetPlaceholder: "字元集", + collationPlaceholder: "排序規則", + collation: "排序規則", }, diagram: { title: "關係圖", diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts index 476c06c93..2a926ad98 100644 --- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { combineDataTypeForDatabase, createColumnDrafts, dataTypeLengthInputValue, isDataTypeLengthDisabled, isSqlServerIdentityCompatibleDataType, splitDataType } from "@/lib/table/tableStructureEditorState"; +import { combineDataTypeForDatabase, createColumnDrafts, dataTypeLengthInputValue, isDataTypeLengthDisabled, isMysqlCharacterDataType, isSqlServerIdentityCompatibleDataType, splitDataType } from "@/lib/table/tableStructureEditorState"; describe("tableStructureEditorState", () => { it("keeps mysql unsigned attributes in the editable base type", () => { @@ -87,4 +87,28 @@ describe("tableStructureEditorState", () => { expect(isSqlServerIdentityCompatibleDataType("varchar(255)")).toBe(false); expect(isSqlServerIdentityCompatibleDataType("numeric(18, 2)")).toBe(false); }); + + it("identifies MySQL character data types that accept charset/collation", () => { + expect(isMysqlCharacterDataType("char(1)")).toBe(true); + expect(isMysqlCharacterDataType("varchar(255)")).toBe(true); + expect(isMysqlCharacterDataType("tinytext")).toBe(true); + expect(isMysqlCharacterDataType("text")).toBe(true); + expect(isMysqlCharacterDataType("mediumtext")).toBe(true); + expect(isMysqlCharacterDataType("longtext")).toBe(true); + expect(isMysqlCharacterDataType("enum('a','b')")).toBe(true); + expect(isMysqlCharacterDataType("set('x','y')")).toBe(true); + expect(isMysqlCharacterDataType("int")).toBe(false); + expect(isMysqlCharacterDataType("bigint(20) unsigned")).toBe(false); + expect(isMysqlCharacterDataType("decimal(10,2)")).toBe(false); + expect(isMysqlCharacterDataType("float")).toBe(false); + expect(isMysqlCharacterDataType("double")).toBe(false); + expect(isMysqlCharacterDataType("date")).toBe(false); + expect(isMysqlCharacterDataType("datetime")).toBe(false); + expect(isMysqlCharacterDataType("timestamp")).toBe(false); + expect(isMysqlCharacterDataType("json")).toBe(false); + expect(isMysqlCharacterDataType("binary(16)")).toBe(false); + expect(isMysqlCharacterDataType("varbinary(255)")).toBe(false); + expect(isMysqlCharacterDataType("blob")).toBe(false); + expect(isMysqlCharacterDataType("geometry")).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/table/tableColumnTemplates.ts b/apps/desktop/src/lib/table/tableColumnTemplates.ts index 518a9361c..74e5e7c4a 100644 --- a/apps/desktop/src/lib/table/tableColumnTemplates.ts +++ b/apps/desktop/src/lib/table/tableColumnTemplates.ts @@ -75,6 +75,8 @@ function templateColumn(createId: () => string, name: string, dataType: string, defaultValue, comment, isPrimaryKey: false, + characterSet: "", + collation: "", extra: {}, markedForDrop: false, }; diff --git a/apps/desktop/src/lib/table/tableStructureEditorSql.ts b/apps/desktop/src/lib/table/tableStructureEditorSql.ts index d31560eea..0f3e51a1c 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorSql.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorSql.ts @@ -25,6 +25,8 @@ export interface EditableStructureColumn { comment: string; isPrimaryKey: boolean; extra: ColumnExtra; + characterSet?: string; + collation?: string; original?: ColumnInfo; originalPosition?: number; markedForDrop: boolean; diff --git a/apps/desktop/src/lib/table/tableStructureEditorState.ts b/apps/desktop/src/lib/table/tableStructureEditorState.ts index f86d34d37..868679ed4 100644 --- a/apps/desktop/src/lib/table/tableStructureEditorState.ts +++ b/apps/desktop/src/lib/table/tableStructureEditorState.ts @@ -614,6 +614,8 @@ export function createColumnDrafts(columns: ColumnInfo[], databaseType?: Databas defaultValue, comment: column.comment ?? "", isPrimaryKey: column.is_primary_key, + characterSet: column.character_set ?? "", + collation: column.collation ?? "", extra: parseExtraToColumnExtra(column.extra, databaseType), original: { ...column, column_default: column.column_default === null ? null : defaultValue }, originalPosition: index, @@ -809,6 +811,14 @@ export function splitDataType(raw: string): { baseType: string; params: string } return { baseType, params }; } +/** MySQL character/text types that accept `CHARACTER SET` and `COLLATE`. */ +const MYSQL_CHARACTER_DATA_TYPES = new Set(["char", "varchar", "tinytext", "text", "mediumtext", "longtext", "enum", "set"]); + +export function isMysqlCharacterDataType(dataType: string): boolean { + const { baseType } = splitDataType(dataType); + return MYSQL_CHARACTER_DATA_TYPES.has(baseType.trim().replace(/\s+/g, " ").toLowerCase()); +} + export function isSqlServerIdentityCompatibleDataType(rawDataType: string): boolean { const { baseType, params } = splitDataType(rawDataType); const normalized = baseType.trim().replace(/\s+/g, " ").toLowerCase(); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index dbbcdefa6..6eed56642 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -358,6 +358,8 @@ export interface ColumnInfo { numeric_scale?: number | null; character_maximum_length?: number | null; enum_values?: string[] | null; + character_set?: string | null; + collation?: string | null; } export interface IndexInfo { diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index 7f07281b5..583f600f8 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -539,6 +539,7 @@ pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Resu numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() } }) .collect()) diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs index f279441f2..1724edd35 100644 --- a/crates/dbx-core/src/db/elasticsearch_driver.rs +++ b/crates/dbx-core/src/db/elasticsearch_driver.rs @@ -334,6 +334,7 @@ fn push_mapping_column( numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }); } diff --git a/crates/dbx-core/src/db/influxdb_driver.rs b/crates/dbx-core/src/db/influxdb_driver.rs index 0768a6320..234ae865f 100644 --- a/crates/dbx-core/src/db/influxdb_driver.rs +++ b/crates/dbx-core/src/db/influxdb_driver.rs @@ -424,6 +424,7 @@ pub async fn get_columns(client: &InfluxdbClient, database: &str, table: &str) - numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }; let cols: Vec = std::iter::once(time_col) @@ -439,6 +440,7 @@ pub async fn get_columns(client: &InfluxdbClient, database: &str, table: &str) - numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() })) .chain(field_series.first().into_iter().flat_map(|s| s.values.iter()).map(|row| { let data_type = row.get(1).and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); @@ -454,6 +456,7 @@ pub async fn get_columns(client: &InfluxdbClient, database: &str, table: &str) - numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() } })) .collect(); @@ -519,6 +522,7 @@ async fn get_columns_v2(client: &InfluxdbClient, bucket: &str, measurement: &str numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }; let tag_cols = flux_column_values(&tag_result, "_value") .into_iter() @@ -535,6 +539,7 @@ async fn get_columns_v2(client: &InfluxdbClient, bucket: &str, measurement: &str numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }); let field_cols = flux_column_values(&field_result, "_value").into_iter().map(|name| ColumnInfo { name, @@ -548,6 +553,7 @@ async fn get_columns_v2(client: &InfluxdbClient, bucket: &str, measurement: &str numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }); Ok(std::iter::once(time_col).chain(tag_cols).chain(field_cols).collect()) } diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 48c4efed7..e0b4e33de 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -2150,6 +2150,7 @@ fn columns_sql(database: &str, table: &str) -> String { format!( "SELECT c.COLUMN_NAME, c.DATA_TYPE, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, \ c.COLUMN_COMMENT, c.COLUMN_KEY, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, c.CHARACTER_MAXIMUM_LENGTH, \ + c.CHARACTER_SET_NAME, c.COLLATION_NAME \ FROM information_schema.COLUMNS c \ WHERE c.TABLE_SCHEMA = {} AND c.TABLE_NAME = {} \ ORDER BY c.ORDINAL_POSITION", @@ -2326,6 +2327,8 @@ pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Resul numeric_scale: get_opt_i32(row, "NUMERIC_SCALE"), character_maximum_length: get_opt_i32(row, "CHARACTER_MAXIMUM_LENGTH"), enum_values, + character_set: get_opt_str(row, "CHARACTER_SET_NAME").filter(|s| !s.is_empty()), + collation: get_opt_str(row, "COLLATION_NAME").filter(|s| !s.is_empty()), }) }) .collect(); @@ -2359,6 +2362,7 @@ pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> return None; } let key = get_str_by_name(row, "Key"); + let collation = get_opt_str(row, "Collation").filter(|s| !s.is_empty()); Some(ColumnInfo { name, data_type: get_str_by_name(row, "Type"), @@ -2373,6 +2377,11 @@ pub async fn get_columns_show(pool: &MySqlPool, database: &str, table: &str) -> numeric_scale: None, character_maximum_length: None, enum_values: None, + character_set: collation + .as_deref() + .and_then(|c| c.split_once('_').map(|(charset, _)| charset.to_string())) + .filter(|s| !s.is_empty()), + collation, }) }) .collect()) @@ -3314,6 +3323,7 @@ pub async fn get_columns_show_from( return None; } let key = get_str_by_name(row, "Key"); + let collation = get_opt_str(row, "Collation").filter(|s| !s.is_empty()); Some(ColumnInfo { name, data_type: get_str_by_name(row, "Type"), @@ -3328,6 +3338,11 @@ pub async fn get_columns_show_from( numeric_scale: None, character_maximum_length: None, enum_values: None, + character_set: collation + .as_deref() + .and_then(|c| c.split_once('_').map(|(charset, _)| charset.to_string())) + .filter(|s| !s.is_empty()), + collation, }) }) .collect()) diff --git a/crates/dbx-core/src/db/ob_oracle.rs b/crates/dbx-core/src/db/ob_oracle.rs index 43b5ff2a0..cd1b5d226 100644 --- a/crates/dbx-core/src/db/ob_oracle.rs +++ b/crates/dbx-core/src/db/ob_oracle.rs @@ -155,6 +155,7 @@ pub async fn get_columns(pool: &mysql_async::Pool, schema: &str, table: &str) -> numeric_scale: scale, character_maximum_length: length, enum_values: None, + ..Default::default() } }) .collect()) diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 56b69645e..a4a664574 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -2216,6 +2216,7 @@ fn column_info_from_row(row: &Row) -> ColumnInfo { numeric_scale: row.try_get::<_, Option>(8).ok().flatten(), character_maximum_length: row.try_get::<_, Option>(9).ok().flatten(), enum_values: parse_enum_values_from_row(row, 10), + ..Default::default() } } diff --git a/crates/dbx-core/src/db/questdb.rs b/crates/dbx-core/src/db/questdb.rs index bcaf8a18a..1894c3ac9 100644 --- a/crates/dbx-core/src/db/questdb.rs +++ b/crates/dbx-core/src/db/questdb.rs @@ -112,6 +112,7 @@ pub async fn get_columns(pool: &Pool, _schema: &str, table: &str) -> Result R numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }) .collect()) } diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 1419484b4..34ef2dc3d 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -1142,6 +1142,7 @@ pub async fn get_columns(pool: &SqliteHandle, _schema: &str, table: &str) -> Res numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }) }) .map_err(|e| e.to_string())?; diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index dc8d5da98..6fa3738e6 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -874,6 +874,7 @@ pub async fn get_linked_server_columns( numeric_scale, character_maximum_length: linked_i32(row, 15), enum_values: None, + ..Default::default() }) }) .collect()) @@ -1435,6 +1436,7 @@ pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str numeric_scale: num_scale, character_maximum_length: max_len, enum_values: None, + ..Default::default() } }) .collect()) diff --git a/crates/dbx-core/src/db/turso_driver.rs b/crates/dbx-core/src/db/turso_driver.rs index 54bb1a70d..aff76be5b 100644 --- a/crates/dbx-core/src/db/turso_driver.rs +++ b/crates/dbx-core/src/db/turso_driver.rs @@ -185,6 +185,7 @@ pub async fn get_columns(client: &TursoClient, _schema: &str, table: &str) -> Re numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }) .collect()) } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index de38bb44b..3997a391b 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -237,6 +237,7 @@ pub fn duckdb_query_columns_in_database_with_attached( numeric_scale: None, character_maximum_length: None, enum_values: None, + ..Default::default() }) }) .map_err(|e| e.to_string())?; @@ -1239,6 +1240,7 @@ fn oracle_columns_from_query_result(result: db::QueryResult) -> Vec Vec Ta let has_attribute_change = options.column.data_type.trim() != original.data_type.trim() || options.column.is_nullable != original.is_nullable || normalize_default(Some(&options.column.default_value)) != original_default(&options.column) - || clean(&options.column.comment) != original_comment(&options.column); + || clean(&options.column.comment) != original_comment(&options.column) + || (is_mysql_character_data_type(&options.column.data_type) + && (options.column.character_set.trim() != original.character_set.as_deref().unwrap_or("") + || options.column.collation.trim() != original.collation.as_deref().unwrap_or(""))); if has_rename && !capabilities.rename_column { warnings.push(format!("Renaming columns is not supported for {database_label} from this editor.")); @@ -727,4 +730,7 @@ pub(super) fn has_existing_column_attribute_change(column: &EditableStructureCol || column.is_nullable != original.is_nullable || normalize_default(Some(&column.default_value)) != original_default(column) || clean(&column.comment) != original_comment(column) + || (is_mysql_character_data_type(&column.data_type) + && (column.character_set.trim() != original.character_set.as_deref().unwrap_or("") + || column.collation.trim() != original.collation.as_deref().unwrap_or(""))) } diff --git a/crates/dbx-core/src/table_structure_sql/column_format.rs b/crates/dbx-core/src/table_structure_sql/column_format.rs index c38b09614..758a86297 100644 --- a/crates/dbx-core/src/table_structure_sql/column_format.rs +++ b/crates/dbx-core/src/table_structure_sql/column_format.rs @@ -10,6 +10,15 @@ pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStru parts.insert(0, "IF NOT EXISTS".to_string()); return parts.join(" "); } + // CHARACTER SET / COLLATE — MySQL character data types only + if dialect == StructureDialect::Mysql && is_mysql_character_data_type(&column.data_type) { + if !column.character_set.trim().is_empty() { + parts.push(format!("CHARACTER SET {}", quote_ident(dialect, &column.character_set))); + } + if !column.collation.trim().is_empty() { + parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation))); + } + } if !column.is_nullable && !is_oracle_like(dialect) && dialect != StructureDialect::ClickHouse { parts.push("NOT NULL".to_string()); } @@ -329,3 +338,20 @@ pub(super) fn questdb_column_type(column: &EditableStructureColumn) -> String { None => data_type, } } + +/// Returns `true` when the data type is a MySQL character/string type that accepts +/// `CHARACTER SET` and `COLLATE` clauses in column definitions. +/// +/// Character types that support per-column charset/collation: +/// `char`, `varchar`, `tinytext`, `text`, `mediumtext`, `longtext`, `enum`, `set` +/// +/// Numeric, temporal, spatial, binary/blob, and JSON types do NOT support these clauses. +pub(super) fn is_mysql_character_data_type(data_type: &str) -> bool { + let trimmed = data_type.trim(); + let base_type = match trimmed.find('(') { + Some(open_index) => trimmed[..open_index].trim(), + None => trimmed, + }; + let normalized = base_type.split_whitespace().collect::>().join(" ").to_ascii_lowercase(); + matches!(normalized.as_str(), "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext" | "enum" | "set") +} diff --git a/crates/dbx-core/src/table_structure_sql/columns.rs b/crates/dbx-core/src/table_structure_sql/columns.rs index ad1076003..aedc815e5 100644 --- a/crates/dbx-core/src/table_structure_sql/columns.rs +++ b/crates/dbx-core/src/table_structure_sql/columns.rs @@ -4,7 +4,7 @@ use super::column_alter::{ build_postgres_existing_column_sql, build_questdb_existing_column_sql, build_sqlite_existing_column_sql, build_sqlserver_existing_column_sql, has_column_extra_change, has_existing_column_attribute_change, }; -use super::column_format::column_definition; +use super::column_format::{column_definition, is_mysql_character_data_type}; use super::comments::build_sqlserver_column_comment_sql; use super::dialect::{capabilities_for, database_label, StructureDialect}; use super::types::{EditableStructureColumn, TableStructureSqlOptions}; @@ -119,6 +119,9 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu || column.is_nullable != original.is_nullable || normalize_default(Some(&column.default_value)) != original_default(column) || clean(&column.comment) != original_comment(column) + || (is_mysql_character_data_type(&column.data_type) + && (column.character_set.trim() != original.character_set.as_deref().unwrap_or("") + || column.collation.trim() != original.collation.as_deref().unwrap_or(""))) || has_column_extra_change(column); if has_position_change && !capabilities.reorder_column { warnings.push(format!("Reordering columns is not supported for {database_label} from this editor.")); diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 75723fa5d..703f9a196 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -14,6 +14,8 @@ fn column(name: &str) -> EditableStructureColumn { original: None, original_position: None, marked_for_drop: false, + character_set: String::new(), + collation: String::new(), } } @@ -75,6 +77,7 @@ fn builds_mysql_column_and_index_changes() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let mut email = column("email"); email.is_nullable = false; @@ -151,6 +154,7 @@ fn doris_table_editor_renames_column_without_mysql_change_syntax() { is_primary_key: false, extra: None, comment: Some("Group DTP".to_string()), + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -182,6 +186,7 @@ fn doris_single_column_alter_renames_then_modifies_column_definition() { is_primary_key: false, extra: None, comment: Some("Division DTP".to_string()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -283,6 +288,7 @@ fn builds_informix_column_and_index_changes() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let mut email = column("email"); email.is_nullable = false; @@ -296,6 +302,7 @@ fn builds_informix_column_and_index_changes() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut old_index = index("idx_old", &["name"]); old_index.marked_for_drop = true; @@ -350,6 +357,7 @@ fn oracle_does_not_generate_drop_sql_for_all_columns() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut name = column("name"); name.marked_for_drop = true; @@ -361,6 +369,7 @@ fn oracle_does_not_generate_drop_sql_for_all_columns() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -397,6 +406,7 @@ fn oracle_timestamp_default_precedes_nullability_in_modify_sql() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -425,6 +435,7 @@ fn oracle_timestamp_precision_change_does_not_repeat_unchanged_nullability() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -535,6 +546,7 @@ fn manticoresearch_builds_add_and_drop_column_sql() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut name = column("name"); @@ -580,6 +592,7 @@ fn gbase8a_uses_limited_mysql_ddl() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let new_col = column("nickname"); let mut old_col = column("old_col"); @@ -592,6 +605,7 @@ fn gbase8a_uses_limited_mysql_ddl() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut index = index("idx_users_email", &["display_email"]); index.original = Some(IndexInfo { @@ -643,6 +657,7 @@ fn gbase8a_allows_mysql_style_column_reorder() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut name = column("name"); @@ -655,6 +670,7 @@ fn gbase8a_allows_mysql_style_column_reorder() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut email = column("email"); @@ -667,6 +683,7 @@ fn gbase8a_allows_mysql_style_column_reorder() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -698,6 +715,7 @@ fn manticoresearch_does_not_drop_id_column() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -734,6 +752,7 @@ fn manticoresearch_warns_when_existing_column_properties_change() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut resource = column("resource"); @@ -747,6 +766,7 @@ fn manticoresearch_warns_when_existing_column_properties_change() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut old_resource = column("old_resource"); @@ -760,6 +780,7 @@ fn manticoresearch_warns_when_existing_column_properties_change() { is_primary_key: false, extra: Some("secondary_index='1'".to_string()), comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -997,6 +1018,7 @@ fn warns_for_sqlite_unsafe_column_changes() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1084,6 +1106,7 @@ fn builds_mysql_column_reorder_statements() { is_primary_key: true, extra: None, comment: None, + ..Default::default() }); let mut email = column("email"); @@ -1096,6 +1119,7 @@ fn builds_mysql_column_reorder_statements() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut name = column("display_name"); @@ -1110,6 +1134,7 @@ fn builds_mysql_column_reorder_statements() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1143,6 +1168,7 @@ fn mysql_add_column_before_existing_column_does_not_reorder_shifted_column() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let new_column = column("sss"); @@ -1161,6 +1187,7 @@ fn mysql_add_column_before_existing_column_does_not_reorder_shifted_column() { is_primary_key: false, extra: None, comment: Some("tenant id".to_string()), + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1194,6 +1221,7 @@ fn mysql_existing_column_reorder_does_not_reorder_columns_shifted_by_prior_move( is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut name = column("name"); @@ -1206,6 +1234,7 @@ fn mysql_existing_column_reorder_does_not_reorder_columns_shifted_by_prior_move( is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut email = column("email"); @@ -1218,6 +1247,7 @@ fn mysql_existing_column_reorder_does_not_reorder_columns_shifted_by_prior_move( is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1250,6 +1280,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut col_1 = column("col_1"); @@ -1262,6 +1293,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut col_2 = column("col_2"); @@ -1274,6 +1306,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut col_3 = column("col_3"); @@ -1286,6 +1319,7 @@ fn mysql_moving_first_column_to_end_uses_single_reorder_statement() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1411,6 +1445,7 @@ fn sqlserver_default_changes_drop_old_constraints_with_isolated_batches() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let mut active = column("active"); @@ -1425,6 +1460,7 @@ fn sqlserver_default_changes_drop_old_constraints_with_isolated_batches() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1610,6 +1646,7 @@ fn sqlserver_unchanged_identity_extra_does_not_mark_existing_column_changed() { is_primary_key: true, extra: Some("IDENTITY(1,1)".to_string()), comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1647,6 +1684,7 @@ fn sqlserver_existing_column_identity_change_warns_without_unchanged_foreign_key is_primary_key: true, extra: None, comment: None, + ..Default::default() }); let mut user_fk = foreign_key("fk_orders_user_id", "user_id", "users", "id"); @@ -1730,6 +1768,7 @@ fn builds_clickhouse_nullable_comment_and_reorder_statements() { is_primary_key: false, extra: None, comment: Some("old status".to_string()), + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1773,6 +1812,7 @@ fn builds_h2_schema_qualified_existing_column_statements() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1815,6 +1855,7 @@ fn builds_postgres_alter_table_add_primary_key() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1847,6 +1888,7 @@ fn builds_postgres_alter_table_drop_primary_key() { is_primary_key: true, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1880,6 +1922,7 @@ fn builds_mysql_alter_table_change_primary_key() { is_primary_key: true, extra: None, comment: None, + ..Default::default() }); let mut new_pk = column("uuid"); @@ -1895,6 +1938,7 @@ fn builds_mysql_alter_table_change_primary_key() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1930,6 +1974,7 @@ fn builds_no_statements_when_primary_key_unchanged() { is_primary_key: true, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -1962,6 +2007,7 @@ fn warns_sqlite_cannot_alter_primary_key() { is_primary_key: false, extra: None, comment: None, + ..Default::default() }); let result = build_table_structure_change_sql(TableStructureSqlOptions { @@ -2230,6 +2276,7 @@ fn postgres_timestamp_literal_is_quoted() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -2255,6 +2302,7 @@ fn mysql_single_column_alter_quotes_datetime_literal() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -2498,6 +2546,7 @@ fn postgres_varchar_default_is_quoted() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -2523,6 +2572,7 @@ fn postgres_empty_string_default_is_not_quoted_again() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -2548,6 +2598,7 @@ fn postgres_string_default_cast_matches_plain_literal() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -2573,6 +2624,7 @@ fn postgres_integer_default_is_not_quoted() { is_primary_key: false, extra: None, comment: Some(String::new()), + ..Default::default() }); let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { @@ -2584,3 +2636,174 @@ fn postgres_integer_default_is_not_quoted() { assert_eq!(result.statements, vec!["ALTER TABLE \"core\".\"products\" ALTER COLUMN \"stock\" SET DEFAULT 0;"]); } + +#[test] +fn mysql_character_column_add_with_charset_collation() { + let mut col = column("name"); + col.data_type = "varchar(255)".to_string(); + col.character_set = "utf8mb4".to_string(); + col.collation = "utf8mb4_unicode_ci".to_string(); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: "users".to_string(), + columns: vec![col], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec![ + "ALTER TABLE `users` ADD COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`;" + ] + ); +} + +#[test] +fn mysql_numeric_column_omits_charset_collation_in_column_definition() { + let mut col = column("score"); + col.data_type = "int".to_string(); + // Even if charset/collation are set on the editable column, they must NOT + // appear in the DDL because int does not accept CHARACTER SET or COLLATE. + col.character_set = "utf8mb4".to_string(); + col.collation = "utf8mb4_unicode_ci".to_string(); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: "games".to_string(), + columns: vec![col], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert!(result.statements.len() == 1); + let sql = &result.statements[0]; + assert!(!sql.contains("CHARACTER SET")); + assert!(!sql.contains("COLLATE")); + assert!(sql.contains("int")); +} + +#[test] +fn mysql_numeric_column_ignores_charset_collation_in_change_detection() { + // When an existing INT column has no original character_set / collation but + // the editable draft carries stale values, the column should NOT be flagged + // as having an attribute change. + let mut col = column("score"); + col.data_type = "int".to_string(); + col.character_set = "utf8mb4".to_string(); + col.collation = "utf8mb4_unicode_ci".to_string(); + col.original = Some(ColumnInfo { + name: "score".to_string(), + data_type: "int".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: "games".to_string(), + columns: vec![col], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + // No ALTER should be emitted — charset/collation changes on + // non-character columns are no-ops. + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements, Vec::::new()); +} + +#[test] +fn mysql_character_column_detects_charset_collation_change() { + let mut col = column("name"); + col.data_type = "varchar(255)".to_string(); + col.character_set = "utf8mb4".to_string(); + col.collation = "utf8mb4_unicode_ci".to_string(); + col.original = Some(ColumnInfo { + name: "name".to_string(), + data_type: "varchar(255)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + ..Default::default() + }); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: "users".to_string(), + columns: vec![col], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["ALTER TABLE `users` MODIFY COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`;"] + ); +} + +#[test] +fn mysql_character_column_preserves_charset_collation_on_other_change() { + // Changing the default value on a character column should still + // re-emit the charset/collation clauses so they are not lost. + let mut col = column("name"); + col.data_type = "varchar(255)".to_string(); + col.character_set = "utf8mb4".to_string(); + col.collation = "utf8mb4_unicode_ci".to_string(); + col.default_value = "guest".to_string(); + col.original = Some(ColumnInfo { + name: "name".to_string(), + data_type: "varchar(255)".to_string(), + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + character_set: Some("utf8mb4".to_string()), + collation: Some("utf8mb4_unicode_ci".to_string()), + }); + + let result = build_table_structure_change_sql(TableStructureSqlOptions { + database_type: Some(DatabaseType::Mysql), + schema: None, + table_name: "users".to_string(), + columns: vec![col], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + table_comment: None, + original_table_comment: None, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!( + result.statements, + vec!["ALTER TABLE `users` MODIFY COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci` DEFAULT 'guest';"] + ); +} diff --git a/crates/dbx-core/src/table_structure_sql/types.rs b/crates/dbx-core/src/table_structure_sql/types.rs index f7d5e166f..9b628b774 100644 --- a/crates/dbx-core/src/table_structure_sql/types.rs +++ b/crates/dbx-core/src/table_structure_sql/types.rs @@ -23,6 +23,10 @@ pub struct EditableStructureColumn { pub original_position: Option, #[serde(default)] pub marked_for_drop: bool, + #[serde(default)] + pub character_set: String, + #[serde(default)] + pub collation: String, } #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] @@ -55,7 +59,7 @@ pub struct ColumnIdentity { pub increment: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct ColumnInfo { pub name: String, pub data_type: String, @@ -67,6 +71,10 @@ pub struct ColumnInfo { pub extra: Option, #[serde(default)] pub comment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub character_set: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collation: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 1c6c6f5ac..4feac5c77 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -2489,6 +2489,7 @@ fn mongo_columns_from_documents(documents: &[serde_json::Value]) -> Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ColumnInfo { pub name: String, pub data_type: String, @@ -122,6 +122,10 @@ pub struct ColumnInfo { pub character_maximum_length: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub enum_values: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub character_set: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collation: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/dbx-core/tests/live_sqlserver_completion.rs b/crates/dbx-core/tests/live_sqlserver_completion.rs index 7ec787428..774df5e3b 100644 --- a/crates/dbx-core/tests/live_sqlserver_completion.rs +++ b/crates/dbx-core/tests/live_sqlserver_completion.rs @@ -187,9 +187,12 @@ fn structure_column( is_primary_key: false, extra: None, comment: None, + ..Default::default() }), original_position: None, marked_for_drop: false, + character_set: String::new(), + collation: String::new(), } } diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index f089e62e4..ac137ba52 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -273,7 +273,7 @@ test("normalizes table font size", () => { assert.equal(normalizeEditorSettings({}).tableFontSize, 13); assert.equal(normalizeEditorSettings({ tableFontSize: 12 }).tableFontSize, 12); assert.equal(normalizeEditorSettings({ tableFontSize: 14.6 }).tableFontSize, 15); - assert.equal(normalizeEditorSettings({ tableFontSize: 8 }).tableFontSize, 12); + assert.equal(normalizeEditorSettings({ tableFontSize: 8 }).tableFontSize, 8); assert.equal(normalizeEditorSettings({ tableFontSize: 20 }).tableFontSize, 16); assert.equal(normalizeEditorSettings({ tableFontSize: "large" as any }).tableFontSize, 13); }); diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts index 3656e6459..d35ed107f 100644 --- a/packages/node-core/src/database.ts +++ b/packages/node-core/src/database.ts @@ -34,6 +34,8 @@ export interface ColumnInfo { numeric_scale?: number | null; character_maximum_length?: number | null; enum_values?: string[] | null; + character_set?: string | null; + collation?: string | null; } export interface QueryResult { @@ -564,11 +566,13 @@ interface BridgeColumnInfo { numeric_scale?: number | null; character_maximum_length?: number | null; enum_values?: string[] | null; + character_set?: string | null; + collation?: string | null; } const POSTGRES_DESCRIBE_TABLE_SQL = `SELECT c.column_name AS name, CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment, CASE WHEN enum_t.oid IS NULL THEN NULL ELSE COALESCE((SELECT array_to_json(array_agg(e.enumlabel ORDER BY e.enumsortorder)) FROM pg_enum e WHERE e.enumtypid = enum_t.oid), '[]'::json) END AS enum_values FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) LEFT JOIN pg_namespace type_ns ON type_ns.nspname = c.udt_schema LEFT JOIN pg_type t ON t.typnamespace = type_ns.oid AND t.typname = c.udt_name LEFT JOIN pg_type enum_t ON enum_t.oid = CASE WHEN t.typtype = 'd' THEN t.typbasetype WHEN t.typtype = 'e' THEN t.oid ELSE NULL END AND enum_t.typtype = 'e' WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`; const POSTGRES_DESCRIBE_TABLE_COMPAT_SQL = `SELECT c.column_name AS name, CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, c.is_nullable = 'YES' AS is_nullable, c.column_default, CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_primary_key, col_description(cls.oid, c.ordinal_position) AS comment, NULL AS enum_values FROM information_schema.columns c LEFT JOIN information_schema.key_column_usage kcu ON kcu.table_schema = c.table_schema AND kcu.table_name = c.table_name AND kcu.column_name = c.column_name LEFT JOIN information_schema.table_constraints tc ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN pg_class cls ON cls.relname = c.table_name AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) WHERE c.table_schema = $1 AND c.table_name = $2 ORDER BY c.ordinal_position`; -const MYSQL_DESCRIBE_TABLE_SQL = `SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, c.COLUMN_TYPE AS column_type, c.IS_NULLABLE = 'YES' AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY = 'PRI' AS is_primary_key, c.COLUMN_COMMENT AS comment FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`; +const MYSQL_DESCRIBE_TABLE_SQL = `SELECT c.COLUMN_NAME AS name, c.DATA_TYPE AS data_type, c.COLUMN_TYPE AS column_type, c.IS_NULLABLE = 'YES' AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY = 'PRI' AS is_primary_key, c.COLUMN_COMMENT AS comment, c.CHARACTER_SET_NAME AS character_set, c.COLLATION_NAME AS collation FROM information_schema.COLUMNS c WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION`; function normalizeEnumValues(value: unknown): string[] | null { if (value == null) return null; @@ -649,6 +653,8 @@ function mapDescribeTableColumn( numeric_precision?: number | null; numeric_scale?: number | null; character_maximum_length?: number | null; + character_set?: string | null; + collation?: string | null; }, enumValues: string[] | null, ): ColumnInfo { @@ -664,6 +670,8 @@ function mapDescribeTableColumn( if ("numeric_precision" in row) column.numeric_precision = row.numeric_precision; if ("numeric_scale" in row) column.numeric_scale = row.numeric_scale; if ("character_maximum_length" in row) column.character_maximum_length = row.character_maximum_length; + if ("character_set" in row) column.character_set = row.character_set ?? null; + if ("collation" in row) column.collation = row.collation ?? null; return column; }