feat(mysql): edit column charset and collation

This commit is contained in:
DengQingNian 2026-07-10 15:13:57 +08:00 committed by GitHub
parent 0674433eea
commit e429c8945b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 524 additions and 20 deletions

View File

@ -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<CreateDatabaseCharsetMetadata>();
const charsetMetadataLoading = ref(false);
const mysqlCharsetOptions = computed<string[]>(() => {
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) => {
</Popover>
</div>
</td>
<td v-if="showCharacterSet" :class="structureCellClass">
<SearchableSelect
:model-value="columnCharset(column)"
:options="mysqlCharsetOptions"
:placeholder="t('structureEditor.charsetPlaceholder')"
:search-placeholder="t('structureEditor.charsetPlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:allow-custom="true"
:disabled="isColumnCharsetDisabled(column)"
:trigger-class="[structureMonoControlClass, 'w-20']"
@update:model-value="(v: string) => onCharsetChange(column, v)"
/>
</td>
<td v-if="showCharacterSet" :class="structureCellClass">
<SearchableSelect
:model-value="columnCollation(column)"
:options="collationOptionsForCharset(columnCharset(column))"
:placeholder="t('structureEditor.collationPlaceholder')"
:search-placeholder="t('structureEditor.collationPlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:allow-custom="true"
:disabled="isColumnCharsetDisabled(column)"
:trigger-class="[structureMonoControlClass, 'w-28']"
@update:model-value="(v: string) => (column.collation = v)"
/>
</td>
<td v-if="showExtendedProperties" :class="structureCellClass">
<div :class="structurePropertyListClass">
<!-- Manticore Search: character data type properties -->

View File

@ -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...",

View File

@ -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...",

View File

@ -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...",

View File

@ -1804,6 +1804,10 @@ export default withEnglishFallback({
defaultValue: "デフォルト",
defaultValuePresets: "デフォルト値プリセット",
comment: "コメント",
characterSet: "文字セット",
charsetPlaceholder: "文字セット",
collation: "照合順序",
collationPlaceholder: "照合順序",
editComment: "コメントを編集",
commentPlaceholder: "列コメントを入力...",
tableCommentPlaceholder: "テーブルコメントを入力...",

View File

@ -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...",

View File

@ -1839,6 +1839,10 @@ export default withEnglishFallback({
defaultValue: "默认值",
defaultValuePresets: "默认值预设",
comment: "注释",
characterSet: "字符集",
charsetPlaceholder: "字符集",
collation: "排序规则",
collationPlaceholder: "排序规则",
editComment: "编辑注释",
commentPlaceholder: "输入字段注释...",
tableCommentPlaceholder: "输入表注释...",

View File

@ -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: "關係圖",

View File

@ -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);
});
});

View File

@ -75,6 +75,8 @@ function templateColumn(createId: () => string, name: string, dataType: string,
defaultValue,
comment,
isPrimaryKey: false,
characterSet: "",
collation: "",
extra: {},
markedForDrop: false,
};

View File

@ -25,6 +25,8 @@ export interface EditableStructureColumn {
comment: string;
isPrimaryKey: boolean;
extra: ColumnExtra;
characterSet?: string;
collation?: string;
original?: ColumnInfo;
originalPosition?: number;
markedForDrop: boolean;

View File

@ -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();

View File

@ -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 {

View File

@ -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())

View File

@ -334,6 +334,7 @@ fn push_mapping_column(
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
});
}

View File

@ -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<ColumnInfo> = 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())
}

View File

@ -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())

View File

@ -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())

View File

@ -2216,6 +2216,7 @@ fn column_info_from_row(row: &Row) -> ColumnInfo {
numeric_scale: row.try_get::<_, Option<i32>>(8).ok().flatten(),
character_maximum_length: row.try_get::<_, Option<i32>>(9).ok().flatten(),
enum_values: parse_enum_values_from_row(row, 10),
..Default::default()
}
}

View File

@ -112,6 +112,7 @@ pub async fn get_columns(pool: &Pool, _schema: &str, table: &str) -> Result<Vec<
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
}
})
.collect())

View File

@ -146,6 +146,7 @@ pub async fn get_columns(client: &RqliteClient, _schema: &str, table: &str) -> R
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
})
.collect())
}

View File

@ -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())?;

View File

@ -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())

View File

@ -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())
}

View File

@ -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<db::ColumnIn
numeric_scale: scale,
character_maximum_length: length,
enum_values: None,
..Default::default()
})
})
.collect()
@ -2173,6 +2175,7 @@ fn presto_like_columns_from_query_result(result: &db::QueryResult) -> Vec<db::Co
numeric_scale: presto_like_numeric_scale(&data_type),
character_maximum_length: presto_like_character_maximum_length(&data_type),
enum_values: None,
..Default::default()
})
})
.collect()
@ -2274,6 +2277,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
}
}
@ -5498,6 +5502,7 @@ mod object_source_tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
};
let mut ignored = column.clone();
ignored.name = "EMPTY_COMMENT".to_string();
@ -5531,6 +5536,7 @@ mod object_source_tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
};
let ddl = append_oracle_comments_to_ddl(
@ -5565,6 +5571,7 @@ mod ddl_tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
}
}

View File

@ -185,6 +185,7 @@ pub fn duckdb_query_columns_in_database_with_attached(
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
..Default::default()
})
})
.map_err(|e| e.to_string())?;

View File

@ -203,6 +203,7 @@ mod tests {
numeric_precision: None,
numeric_scale: None,
character_maximum_length: None,
..Default::default()
}
}

View File

@ -1509,6 +1509,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
}
}
@ -1963,6 +1964,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
}),
target: None,
changes: Vec::new(),

View File

@ -1,4 +1,4 @@
use super::column_format::{clickhouse_column_type, column_data_type, column_definition};
use super::column_format::{clickhouse_column_type, column_data_type, column_definition, is_mysql_character_data_type};
use super::columns::build_drop_column_sql;
use super::comments::build_sqlserver_column_comment_sql;
use super::dialect::{capabilities_for, database_label, StructureDialect};
@ -54,7 +54,10 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> 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("")))
}

View File

@ -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::<Vec<_>>().join(" ").to_ascii_lowercase();
matches!(normalized.as_str(), "char" | "varchar" | "tinytext" | "text" | "mediumtext" | "longtext" | "enum" | "set")
}

View File

@ -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."));

View File

@ -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::<String>::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::<String>::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::<String>::new());
assert_eq!(result.statements, Vec::<String>::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::<String>::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::<String>::new());
assert_eq!(
result.statements,
vec!["ALTER TABLE `users` MODIFY COLUMN `name` varchar(255) CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci` DEFAULT 'guest';"]
);
}

View File

@ -23,6 +23,10 @@ pub struct EditableStructureColumn {
pub original_position: Option<usize>,
#[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<i64>,
}
#[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<String>,
#[serde(default)]
pub comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub character_set: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collation: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@ -2489,6 +2489,7 @@ fn mongo_columns_from_documents(documents: &[serde_json::Value]) -> Vec<db::Colu
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
}
})
.collect()
@ -3734,6 +3735,7 @@ where
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
});
}
sql_target_column_names = sql_target_columns.iter().map(|column| column.name.clone()).collect();
@ -4671,6 +4673,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
}
}
@ -5524,6 +5527,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
},
db::ColumnInfo {
name: "identity_id".to_string(),
@ -5537,6 +5541,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
},
db::ColumnInfo {
name: "computed_id".to_string(),
@ -5550,6 +5555,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
},
],
"users",
@ -5624,6 +5630,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
},
db::ColumnInfo {
name: "name".to_string(),
@ -5637,6 +5644,7 @@ mod tests {
numeric_scale: None,
character_maximum_length: None,
enum_values: None,
..Default::default()
},
];
let source_ddl = crate::schema::render_postgres_table_ddl("public", "it_quick_entry", &columns, &[], &[]);

View File

@ -108,7 +108,7 @@ pub struct ObjectSource {
pub editable: Option<bool>,
}
#[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<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub character_set: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]

View File

@ -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(),
}
}

View File

@ -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);
});

View File

@ -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;
}