fix(mysql): show inherited charset and collation in structure editor
This commit is contained in:
1 parent
48410b70b4
commit
06dcd0013c
16 files changed
+562
-91
No files matched your search
@@ -37,6 +37,7 @@ import { invalidateObjectMetadataCache, loadObjectMetadataFacet, type ObjectMeta
|
|||||||
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||||||
import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql";
|
import { type BuildTableStructureChangeSqlOptions, type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/table/tableStructureEditorSql";
|
||||||
import { buildMysqlAutoIncrementCounterStatement, canEditMysqlAutoIncrementCounter, refreshMysqlAutoIncrementCounterDraft } from "@/lib/table/mysqlAutoIncrementCounter";
|
import { buildMysqlAutoIncrementCounterStatement, canEditMysqlAutoIncrementCounter, refreshMysqlAutoIncrementCounterDraft } from "@/lib/table/mysqlAutoIncrementCounter";
|
||||||
|
import { mysqlTableCollationSql, parseMysqlTableCollation } from "@/lib/table/mysqlTableCollation";
|
||||||
import { MYSQL_STORAGE_ENGINES_SQL, mysqlTableEngineSql, mysqlTableEngineSqlOption, parseMysqlTableEngineMetadata, refreshMysqlTableEngineDraft, supportsMysqlTableEngine } from "@/lib/table/mysqlTableEngine";
|
import { MYSQL_STORAGE_ENGINES_SQL, mysqlTableEngineSql, mysqlTableEngineSqlOption, parseMysqlTableEngineMetadata, refreshMysqlTableEngineDraft, supportsMysqlTableEngine } from "@/lib/table/mysqlTableEngine";
|
||||||
import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates";
|
import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/table/tableColumnTemplates";
|
||||||
import { getMysqlDataTypeHelp } from "@/lib/table/mysqlDataTypeHelp";
|
import { getMysqlDataTypeHelp } from "@/lib/table/mysqlDataTypeHelp";
|
||||||
@@ -928,6 +929,9 @@ const showCharacterSet = computed(() => structureDialect.value === "mysql");
|
|||||||
|
|
||||||
const serverCharsetMetadata = ref<CreateDatabaseCharsetMetadata>();
|
const serverCharsetMetadata = ref<CreateDatabaseCharsetMetadata>();
|
||||||
const charsetMetadataLoading = ref(false);
|
const charsetMetadataLoading = ref(false);
|
||||||
|
// The table's own default collation, used only to keep inherited charsets out of the
|
||||||
|
// generated DDL. Columns keep the real values MySQL reports so the pickers stay filled.
|
||||||
|
const mysqlTableDefaultCollation = ref("");
|
||||||
|
|
||||||
const mysqlCharsetOptions = computed<string[]>(() => {
|
const mysqlCharsetOptions = computed<string[]>(() => {
|
||||||
const meta = serverCharsetMetadata.value;
|
const meta = serverCharsetMetadata.value;
|
||||||
@@ -956,6 +960,22 @@ async function loadCharsetMetadata() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMysqlTableDefaultCollation() {
|
||||||
|
if (!showCharacterSet.value || isCreateMode.value || !props.connectionId || !props.database || !props.tableName) {
|
||||||
|
mysqlTableDefaultCollation.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.ensureConnected(props.connectionId);
|
||||||
|
const result = await api.executeQuery(props.connectionId, props.database, mysqlTableCollationSql(props.database, props.tableName), undefined, undefined, { maxRows: 1 });
|
||||||
|
mysqlTableDefaultCollation.value = parseMysqlTableCollation(result);
|
||||||
|
} catch {
|
||||||
|
// Optional metadata: without it the DDL just spells out the charset the column
|
||||||
|
// already has, which is equivalent SQL — never block the editor on this lookup.
|
||||||
|
mysqlTableDefaultCollation.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onCharsetChange(column: EditableStructureColumn, charset: string) {
|
function onCharsetChange(column: EditableStructureColumn, charset: string) {
|
||||||
column.characterSet = charset;
|
column.characterSet = charset;
|
||||||
// If the collation is no longer valid for the new charset, clear it so the
|
// If the collation is no longer valid for the new charset, clear it so the
|
||||||
@@ -1614,6 +1634,7 @@ function structureChangeOptions(): BuildTableStructureChangeSqlOptions {
|
|||||||
tableComment: tableComment.value,
|
tableComment: tableComment.value,
|
||||||
originalTableComment: isCreateMode.value ? undefined : originalTableComment.value,
|
originalTableComment: isCreateMode.value ? undefined : originalTableComment.value,
|
||||||
mysqlEngine: mysqlTableEngineSqlOption({ value: mysqlTableEngine.value, originalValue: originalMysqlTableEngine.value }, isCreateMode.value, supportsMysqlEngine.value && !mysqlTableEngineLoading.value && !mysqlTableEngineLoadError.value),
|
mysqlEngine: mysqlTableEngineSqlOption({ value: mysqlTableEngine.value, originalValue: originalMysqlTableEngine.value }, isCreateMode.value, supportsMysqlEngine.value && !mysqlTableEngineLoading.value && !mysqlTableEngineLoadError.value),
|
||||||
|
tableCollation: mysqlTableDefaultCollation.value || undefined,
|
||||||
partitioned: isPartitionedParent.value,
|
partitioned: isPartitionedParent.value,
|
||||||
isGaussdbMMode: connection.value?.driver_profile?.toLowerCase() === "gaussdb-m",
|
isGaussdbMMode: connection.value?.driver_profile?.toLowerCase() === "gaussdb-m",
|
||||||
};
|
};
|
||||||
@@ -1772,6 +1793,7 @@ function resetState() {
|
|||||||
mysqlTableEngineLoadRequestId += 1;
|
mysqlTableEngineLoadRequestId += 1;
|
||||||
mysqlTableEngineLoading.value = false;
|
mysqlTableEngineLoading.value = false;
|
||||||
mysqlTableEngineLoadError.value = "";
|
mysqlTableEngineLoadError.value = "";
|
||||||
|
mysqlTableDefaultCollation.value = "";
|
||||||
tableOwner.value = "";
|
tableOwner.value = "";
|
||||||
originalTableOwner.value = "";
|
originalTableOwner.value = "";
|
||||||
tableOwnerLoadRequestId += 1;
|
tableOwnerLoadRequestId += 1;
|
||||||
@@ -2040,6 +2062,7 @@ async function loadStructure(
|
|||||||
// Load live charset/collation metadata from the MySQL server so the column
|
// Load live charset/collation metadata from the MySQL server so the column
|
||||||
// editor shows the correct options for the server version.
|
// editor shows the correct options for the server version.
|
||||||
void loadCharsetMetadata();
|
void loadCharsetMetadata();
|
||||||
|
void loadMysqlTableDefaultCollation();
|
||||||
const nextColumnDrafts = createColumnDrafts(nextColumns, databaseType.value);
|
const nextColumnDrafts = createColumnDrafts(nextColumns, databaseType.value);
|
||||||
const hydratedColumnDrafts = supportsCharacterLengthUnits.value && options.characterLengthUnitsAfterSave ? restoreCharacterLengthUnitsAfterSave(databaseType.value, nextColumnDrafts, options.characterLengthUnitsAfterSave) : nextColumnDrafts;
|
const hydratedColumnDrafts = supportsCharacterLengthUnits.value && options.characterLengthUnitsAfterSave ? restoreCharacterLengthUnitsAfterSave(databaseType.value, nextColumnDrafts, options.characterLengthUnitsAfterSave) : nextColumnDrafts;
|
||||||
columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts);
|
columns.value = applyStoredLocalColumnOrder(hydratedColumnDrafts);
|
||||||
@@ -3862,6 +3885,7 @@ watch(
|
|||||||
originalMysqlTableEngine,
|
originalMysqlTableEngine,
|
||||||
mysqlTableEngineLoading,
|
mysqlTableEngineLoading,
|
||||||
mysqlTableEngineLoadError,
|
mysqlTableEngineLoadError,
|
||||||
|
mysqlTableDefaultCollation,
|
||||||
tableOwner,
|
tableOwner,
|
||||||
ddlDraft,
|
ddlDraft,
|
||||||
columns,
|
columns,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { QueryResult } from "@/types/database";
|
||||||
|
import { mysqlTableCollationSql, parseMysqlTableCollation } from "@/lib/table/mysqlTableCollation";
|
||||||
|
|
||||||
|
function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
|
||||||
|
return { columns, rows, affected_rows: 0, execution_time_ms: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mysqlTableCollation", () => {
|
||||||
|
it("builds mode-independent lookup SQL for quoted and Unicode names", () => {
|
||||||
|
const sql = mysqlTableCollationSql("db'x", "订单\\archive");
|
||||||
|
expect(sql).toContain("TABLE_COLLATION AS table_collation");
|
||||||
|
expect(sql).toContain("TABLE_SCHEMA = CONVERT(X'64622778' USING utf8mb4)");
|
||||||
|
expect(sql).toContain("TABLE_NAME = CONVERT(X'e8aea2e58d955c61726368697665' USING utf8mb4)");
|
||||||
|
expect(sql).not.toContain("db'x");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the reported collation regardless of column-name casing", () => {
|
||||||
|
expect(parseMysqlTableCollation(result(["TABLE_COLLATION"], [["utf8mb4_0900_ai_ci"]]))).toBe("utf8mb4_0900_ai_ci");
|
||||||
|
expect(parseMysqlTableCollation(result(["table_collation"], [[" utf8mb4_bin "]]))).toBe("utf8mb4_bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to no table default when the server reports nothing usable", () => {
|
||||||
|
// MySQL reports TABLE_COLLATION as NULL for views, and the lookup itself may fail.
|
||||||
|
expect(parseMysqlTableCollation(result(["table_collation"], [[null]]))).toBe("");
|
||||||
|
expect(parseMysqlTableCollation(result(["table_collation"], []))).toBe("");
|
||||||
|
expect(parseMysqlTableCollation(result(["engine"], [["InnoDB"]]))).toBe("");
|
||||||
|
expect(parseMysqlTableCollation(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { QueryResult } from "@/types/database";
|
||||||
|
import { mysqlUtf8Literal } from "@/lib/table/mysqlTableEngine";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the table's default collation. MySQL reports the *effective* collation on every
|
||||||
|
* character column and never records whether it was written out explicitly, so a column
|
||||||
|
* matching this value is one that simply inherits the table default. The structure editor
|
||||||
|
* passes it to the SQL builder, which then leaves the redundant `CHARACTER SET`/`COLLATE`
|
||||||
|
* clauses out of the generated DDL — the column metadata itself keeps the real values so
|
||||||
|
* the charset and collation pickers can show what the column currently uses.
|
||||||
|
*/
|
||||||
|
export function mysqlTableCollationSql(database: string, table: string): string {
|
||||||
|
return ["SELECT TABLE_COLLATION AS table_collation", "FROM information_schema.TABLES", `WHERE TABLE_SCHEMA = ${mysqlUtf8Literal(database)}`, ` AND TABLE_NAME = ${mysqlUtf8Literal(table)}`, "LIMIT 1"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty string when the server reported nothing usable — MySQL returns NULL for views. */
|
||||||
|
export function parseMysqlTableCollation(result: QueryResult | undefined): string {
|
||||||
|
if (!result) return "";
|
||||||
|
const index = result.columns.findIndex((column) => column.trim().toLowerCase() === "table_collation");
|
||||||
|
if (index < 0) return "";
|
||||||
|
const value = result.rows[0]?.[index];
|
||||||
|
return value === null || value === undefined ? "" : String(value).trim();
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ export interface MysqlTableEngineDraft {
|
|||||||
|
|
||||||
export const MYSQL_STORAGE_ENGINES_SQL = "SHOW ENGINES";
|
export const MYSQL_STORAGE_ENGINES_SQL = "SHOW ENGINES";
|
||||||
|
|
||||||
function mysqlUtf8Literal(value: string): string {
|
/** Encodes a schema/table name as a byte literal so non-ASCII names survive whatever
|
||||||
|
* charset the session happens to use. Shared with the table collation lookup. */
|
||||||
|
export function mysqlUtf8Literal(value: string): string {
|
||||||
const bytes = new TextEncoder().encode(value);
|
const bytes = new TextEncoder().encode(value);
|
||||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
return `CONVERT(X'${hex}' USING utf8mb4)`;
|
return `CONVERT(X'${hex}' USING utf8mb4)`;
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export interface BuildTableStructureChangeSqlOptions {
|
|||||||
tableComment?: string;
|
tableComment?: string;
|
||||||
originalTableComment?: string;
|
originalTableComment?: string;
|
||||||
mysqlEngine?: string;
|
mysqlEngine?: string;
|
||||||
|
/** MySQL only: the table's current default collation. Columns whose collation merely
|
||||||
|
* matches it inherit the table default, so the backend leaves their redundant
|
||||||
|
* `CHARACTER SET`/`COLLATE` clauses out of the generated DDL. */
|
||||||
|
tableCollation?: string;
|
||||||
/** The target table is a PostgreSQL partitioned parent (`relkind = 'p'`);
|
/** The target table is a PostgreSQL partitioned parent (`relkind = 'p'`);
|
||||||
* the backend rejects `CREATE INDEX CONCURRENTLY` on such tables (fail
|
* the backend rejects `CREATE INDEX CONCURRENTLY` on such tables (fail
|
||||||
* closed) instead of downgrading to a blocking `CREATE INDEX`. */
|
* closed) instead of downgrading to a blocking `CREATE INDEX`. */
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ fn nonblank(value: String) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn query_first_nonblank_string(conn: &mut mysql_async::Conn, sql: &str) -> Option<String> {
|
async fn query_first_nonblank_string(conn: &mut mysql_async::Conn, sql: &str) -> Option<String> {
|
||||||
// MySQL reports nullable metadata such as TABLE_COLLATION as NULL for views.
|
// MySQL reports nullable metadata such as `@@version_comment` as NULL.
|
||||||
// Reading it as String makes mysql_async panic during row conversion.
|
// Reading it as String makes mysql_async panic during row conversion.
|
||||||
match query_first_column::<String>(conn, sql).await {
|
match query_first_column::<String>(conn, sql).await {
|
||||||
Ok(value) => value.and_then(nonblank),
|
Ok(value) => value.and_then(nonblank),
|
||||||
@@ -3697,11 +3697,9 @@ pub async fn list_completion_objects(pool: &MySqlPool, database: &str) -> Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn columns_sql(database: &str, table: &str) -> String {
|
fn columns_sql(database: &str, table: &str) -> String {
|
||||||
// Query only information_schema.COLUMNS and fetch TABLE_COLLATION separately via
|
// Query only information_schema.COLUMNS. A LEFT JOIN onto information_schema.TABLES
|
||||||
// `table_collation_sql`. A LEFT JOIN onto information_schema.TABLES triggers a
|
// triggers a catastrophic plan on MySQL 5.7 (observed ~8s vs ~1ms without the join),
|
||||||
// catastrophic plan on MySQL 5.7 (observed ~8s vs ~1ms without the join), because 5.7's
|
// because 5.7's TABLES metadata is materialized per-query with poor predicate pushdown.
|
||||||
// TABLES metadata is materialized per-query with poor predicate pushdown. The separate
|
|
||||||
// lookup matches the `get_columns_show` fallback path and keeps results identical.
|
|
||||||
format!(
|
format!(
|
||||||
"SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, \
|
"SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, \
|
||||||
COLUMN_COMMENT, COLUMN_KEY, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, \
|
COLUMN_COMMENT, COLUMN_KEY, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, \
|
||||||
@@ -3802,28 +3800,6 @@ fn apply_mysql_generated_column_expressions(columns: &mut [ColumnInfo], expressi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn table_collation_sql(database: &str, table: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = {} AND TABLE_NAME = {} LIMIT 1",
|
|
||||||
quote_value(database),
|
|
||||||
quote_value(table),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_mysql_column_charset_metadata(columns: &mut [ColumnInfo], table_collation: Option<&str>) {
|
|
||||||
let Some(table_collation) = table_collation.filter(|value| !value.trim().is_empty()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for column in columns {
|
|
||||||
if column.collation.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(table_collation)) {
|
|
||||||
// MySQL reports effective values and does not preserve whether an
|
|
||||||
// equivalent table-default collation was explicitly written.
|
|
||||||
column.character_set = None;
|
|
||||||
column.collation = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to reverse CP1252→UTF-8 double-encoding.
|
/// Attempt to reverse CP1252→UTF-8 double-encoding.
|
||||||
///
|
///
|
||||||
/// When Chinese text is written to MySQL through a connection with the wrong
|
/// When Chinese text is written to MySQL through a connection with the wrong
|
||||||
@@ -3964,14 +3940,6 @@ where
|
|||||||
};
|
};
|
||||||
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// When database is empty the COLUMNS query returns no rows, so the
|
|
||||||
// function falls through to get_columns_show and this code path is
|
|
||||||
// never reached. Skip the collation lookup to avoid a pointless query.
|
|
||||||
let table_collation = if database.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
query_first_nonblank_string(&mut conn, &table_collation_sql(database, table)).await
|
|
||||||
};
|
|
||||||
let mut columns: Vec<ColumnInfo> = rows
|
let mut columns: Vec<ColumnInfo> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|row| {
|
.filter_map(|row| {
|
||||||
@@ -4019,7 +3987,6 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
|
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
|
||||||
normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref());
|
|
||||||
Ok(columns)
|
Ok(columns)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4037,11 +4004,6 @@ where
|
|||||||
result.collect_and_drop().await.map_err(|e| e.to_string())?
|
result.collect_and_drop().await.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let table_collation = if database.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
query_first_nonblank_string(&mut conn, &table_collation_sql(database, table)).await
|
|
||||||
};
|
|
||||||
let mut columns: Vec<ColumnInfo> = rows
|
let mut columns: Vec<ColumnInfo> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|row| {
|
.filter_map(|row| {
|
||||||
@@ -4076,7 +4038,6 @@ where
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
|
enrich_mysql_generated_column_expressions(&mut conn, database, table, &mut columns).await;
|
||||||
normalize_mysql_column_charset_metadata(&mut columns, table_collation.as_deref());
|
|
||||||
Ok(columns)
|
Ok(columns)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7106,12 +7067,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mysql_columns_sql_uses_column_key_and_table_default_collation() {
|
fn mysql_columns_sql_uses_column_key_for_primary_keys_without_join() {
|
||||||
let sql = columns_sql("app", "users");
|
let sql = columns_sql("app", "users");
|
||||||
|
|
||||||
assert!(sql.contains("information_schema.COLUMNS"));
|
assert!(sql.contains("information_schema.COLUMNS"));
|
||||||
// TABLE_COLLATION is fetched separately via `table_collation_sql`; the LEFT JOIN onto
|
// The LEFT JOIN onto information_schema.TABLES is intentionally avoided to keep
|
||||||
// information_schema.TABLES is intentionally avoided to keep MySQL 5.7 fast.
|
// MySQL 5.7 fast.
|
||||||
assert!(!sql.contains("information_schema.TABLES"));
|
assert!(!sql.contains("information_schema.TABLES"));
|
||||||
assert!(!sql.contains("TABLE_COLLATION"));
|
assert!(!sql.contains("TABLE_COLLATION"));
|
||||||
assert!(!sql.contains("KEY_COLUMN_USAGE"));
|
assert!(!sql.contains("KEY_COLUMN_USAGE"));
|
||||||
@@ -7175,54 +7136,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mysql_nullable_table_collation_uses_optional_string_conversion() {
|
fn mysql_nullable_metadata_uses_optional_string_conversion() {
|
||||||
let collation = mysql_async::from_value_opt::<Option<String>>(mysql_async::Value::NULL)
|
let collation = mysql_async::from_value_opt::<Option<String>>(mysql_async::Value::NULL)
|
||||||
.expect("Option<String> must accept NULL MySQL metadata values");
|
.expect("Option<String> must accept NULL MySQL metadata values");
|
||||||
|
|
||||||
assert_eq!(collation, None);
|
assert_eq!(collation, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mysql_column_charset_metadata_clears_values_matching_table_default() {
|
|
||||||
let mut columns = vec![
|
|
||||||
ColumnInfo {
|
|
||||||
name: "inherited_name".to_string(),
|
|
||||||
character_set: Some("utf8mb4".to_string()),
|
|
||||||
collation: Some("utf8mb4_unicode_ci".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
ColumnInfo {
|
|
||||||
name: "explicit_other".to_string(),
|
|
||||||
character_set: Some("latin1".to_string()),
|
|
||||||
collation: Some("latin1_bin".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
ColumnInfo { name: "numeric_value".to_string(), ..Default::default() },
|
|
||||||
];
|
|
||||||
|
|
||||||
normalize_mysql_column_charset_metadata(&mut columns, Some("utf8mb4_unicode_ci"));
|
|
||||||
|
|
||||||
assert_eq!((columns[0].character_set.as_deref(), columns[0].collation.as_deref()), (None, None));
|
|
||||||
assert_eq!(columns[1].character_set.as_deref(), Some("latin1"));
|
|
||||||
assert_eq!(columns[1].collation.as_deref(), Some("latin1_bin"));
|
|
||||||
assert_eq!((columns[2].character_set.as_deref(), columns[2].collation.as_deref()), (None, None));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mysql_column_charset_metadata_preserves_values_without_table_default() {
|
|
||||||
let mut columns = vec![ColumnInfo {
|
|
||||||
name: "name".to_string(),
|
|
||||||
character_set: Some("utf8mb4".to_string()),
|
|
||||||
collation: Some("utf8mb4_unicode_ci".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
}];
|
|
||||||
|
|
||||||
normalize_mysql_column_charset_metadata(&mut columns, None);
|
|
||||||
|
|
||||||
assert_eq!(columns[0].character_set.as_deref(), Some("utf8mb4"));
|
|
||||||
assert_eq!(columns[0].collation.as_deref(), Some("utf8mb4_unicode_ci"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_mysql_enum_values_preserves_mysql_literal_edges() {
|
fn parse_mysql_enum_values_preserves_mysql_literal_edges() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -2403,6 +2403,7 @@ mod tests {
|
|||||||
mysql_engine: None,
|
mysql_engine: None,
|
||||||
partitioned: false,
|
partitioned: false,
|
||||||
is_gaussdb_m_mode: false,
|
is_gaussdb_m_mode: false,
|
||||||
|
table_collation: None,
|
||||||
});
|
});
|
||||||
assert_eq!(create.warnings, Vec::<String>::new());
|
assert_eq!(create.warnings, Vec::<String>::new());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub(crate) use util::{oracle_new_object_reference, sqlserver_unicode_string_lite
|
|||||||
|
|
||||||
use crate::models::connection::DatabaseType;
|
use crate::models::connection::DatabaseType;
|
||||||
|
|
||||||
|
use column_format::strip_inherited_mysql_column_charsets;
|
||||||
use columns::{build_column_sql, validate_primary_key_change_scope};
|
use columns::{build_column_sql, validate_primary_key_change_scope};
|
||||||
use comments::build_table_comment_sql;
|
use comments::build_table_comment_sql;
|
||||||
use foreign_keys::build_foreign_key_sql;
|
use foreign_keys::build_foreign_key_sql;
|
||||||
@@ -49,6 +50,7 @@ pub fn build_table_structure_change_sql(mut options: TableStructureSqlOptions) -
|
|||||||
if options.is_gaussdb_m_mode {
|
if options.is_gaussdb_m_mode {
|
||||||
options.database_type = Some(DatabaseType::Mysql);
|
options.database_type = Some(DatabaseType::Mysql);
|
||||||
}
|
}
|
||||||
|
strip_inherited_mysql_column_charsets(&mut options);
|
||||||
let primary_key_errors = validate_primary_key_change_scope(&options);
|
let primary_key_errors = validate_primary_key_change_scope(&options);
|
||||||
if !primary_key_errors.is_empty() {
|
if !primary_key_errors.is_empty() {
|
||||||
return TableStructureSqlResult { statements: Vec::new(), warnings: primary_key_errors };
|
return TableStructureSqlResult { statements: Vec::new(), warnings: primary_key_errors };
|
||||||
|
|||||||
@@ -1,7 +1,41 @@
|
|||||||
use super::dialect::{is_oracle_like, StructureDialect};
|
use super::dialect::{is_oracle_like, StructureDialect};
|
||||||
use super::types::EditableStructureColumn;
|
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||||
use super::util::{clean, format_default_for_sql, normalize_default, quote_ident, quote_string};
|
use super::util::{clean, format_default_for_sql, normalize_default, quote_ident, quote_string};
|
||||||
|
|
||||||
|
/// Drops the `CHARACTER SET` / `COLLATE` inputs of MySQL columns that merely
|
||||||
|
/// inherit the table's default collation, so the generated DDL does not spell
|
||||||
|
/// out clauses the server would apply anyway.
|
||||||
|
///
|
||||||
|
/// MySQL reports the *effective* collation of every character column and never
|
||||||
|
/// records whether it was written out explicitly, so "equals the table default"
|
||||||
|
/// is the only signal available. Omitting the clauses is equivalent to keeping
|
||||||
|
/// them: a column definition without `CHARACTER SET` takes the table default,
|
||||||
|
/// which is exactly the value being dropped here. This runs on the DDL inputs
|
||||||
|
/// only — introspection (`get_columns`) still reports the real values so the
|
||||||
|
/// structure editor can show the column's current charset and collation.
|
||||||
|
///
|
||||||
|
/// The original snapshot is normalized alongside the draft so that an untouched
|
||||||
|
/// column does not register as a charset change and trigger a needless `MODIFY`.
|
||||||
|
pub(super) fn strip_inherited_mysql_column_charsets(options: &mut TableStructureSqlOptions) {
|
||||||
|
let Some(table_collation) = options.table_collation.as_deref().map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let inherits = |collation: &str| collation.trim().eq_ignore_ascii_case(table_collation);
|
||||||
|
for column in &mut options.columns {
|
||||||
|
if inherits(&column.collation) {
|
||||||
|
column.character_set = String::new();
|
||||||
|
column.collation = String::new();
|
||||||
|
}
|
||||||
|
if let Some(original) = column.original.as_mut() {
|
||||||
|
if original.collation.as_deref().is_some_and(inherits) {
|
||||||
|
original.character_set = None;
|
||||||
|
original.collation = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStructureColumn) -> String {
|
pub(super) fn column_definition(dialect: StructureDialect, column: &EditableStructureColumn) -> String {
|
||||||
let data_type = column_data_type(dialect, column);
|
let data_type = column_data_type(dialect, column);
|
||||||
let mut parts = vec![quote_ident(dialect, &column.name), data_type];
|
let mut parts = vec![quote_ident(dialect, &column.name), data_type];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::column_format::{
|
use super::column_format::{
|
||||||
column_data_type, column_extra_clause, has_dameng_identity, is_dameng_identity_compatible_type,
|
column_data_type, column_extra_clause, has_dameng_identity, is_dameng_identity_compatible_type,
|
||||||
is_mysql_character_data_type, is_mysql_timestamp_type,
|
is_mysql_character_data_type, is_mysql_timestamp_type, strip_inherited_mysql_column_charsets,
|
||||||
};
|
};
|
||||||
use super::comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
|
use super::comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
|
||||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||||
@@ -26,6 +26,7 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
|
|||||||
dialect = capabilities.dialect;
|
dialect = capabilities.dialect;
|
||||||
}
|
}
|
||||||
options.table_name = clean(&options.table_name);
|
options.table_name = clean(&options.table_name);
|
||||||
|
strip_inherited_mysql_column_charsets(&mut options);
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
warnings.extend(validate_mysql_engine(&options));
|
warnings.extend(validate_mysql_engine(&options));
|
||||||
// Fail closed: a concurrent-index request on a partitioned parent (or on an
|
// Fail closed: a concurrent-index request on a partitioned parent (or on an
|
||||||
|
|||||||
@@ -1519,6 +1519,7 @@ mod tests {
|
|||||||
mysql_engine: None,
|
mysql_engine: None,
|
||||||
partitioned: false,
|
partitioned: false,
|
||||||
is_gaussdb_m_mode: false,
|
is_gaussdb_m_mode: false,
|
||||||
|
table_collation: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -216,6 +216,14 @@ pub struct TableStructureSqlOptions {
|
|||||||
pub original_table_comment: Option<String>,
|
pub original_table_comment: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub mysql_engine: Option<String>,
|
pub mysql_engine: Option<String>,
|
||||||
|
/// MySQL only: the table's current default collation
|
||||||
|
/// (`information_schema.TABLES.TABLE_COLLATION`). A column whose collation
|
||||||
|
/// merely matches it inherits the table default, so its `CHARACTER SET` /
|
||||||
|
/// `COLLATE` clauses are redundant and are dropped from the generated DDL.
|
||||||
|
/// Introspection keeps reporting the column's real values, which is what
|
||||||
|
/// the structure editor renders in its charset/collation pickers.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub table_collation: Option<String>,
|
||||||
/// Whether the target table is a partitioned parent table (PostgreSQL
|
/// Whether the target table is a partitioned parent table (PostgreSQL
|
||||||
/// `relkind = 'p'`). PostgreSQL rejects `CREATE INDEX CONCURRENTLY` on
|
/// `relkind = 'p'`). PostgreSQL rejects `CREATE INDEX CONCURRENTLY` on
|
||||||
/// partitioned parents, so the builder refuses such a request up front
|
/// partitioned parents, so the builder refuses such a request up front
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ fn change_options(table_name: &str, columns: Vec<EditableStructureColumn>) -> Ta
|
|||||||
mysql_engine: None,
|
mysql_engine: None,
|
||||||
partitioned: false,
|
partitioned: false,
|
||||||
is_gaussdb_m_mode: false,
|
is_gaussdb_m_mode: false,
|
||||||
|
table_collation: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async fn live_postgres_concurrent_index_builds_valid_index() {
|
|||||||
mysql_engine: None,
|
mysql_engine: None,
|
||||||
partitioned: false,
|
partitioned: false,
|
||||||
is_gaussdb_m_mode: false,
|
is_gaussdb_m_mode: false,
|
||||||
|
table_collation: None,
|
||||||
});
|
});
|
||||||
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
|
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -193,6 +194,7 @@ async fn live_postgres_partitioned_parent_concurrent_request_rejected() {
|
|||||||
mysql_engine: None,
|
mysql_engine: None,
|
||||||
partitioned: true,
|
partitioned: true,
|
||||||
is_gaussdb_m_mode: false,
|
is_gaussdb_m_mode: false,
|
||||||
|
table_collation: None,
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.warnings,
|
result.warnings,
|
||||||
|
|||||||
@@ -1010,6 +1010,7 @@ async fn live_sqlserver_table_structure_default_changes_drop_existing_constraint
|
|||||||
mysql_engine: None,
|
mysql_engine: None,
|
||||||
partitioned: false,
|
partitioned: false,
|
||||||
is_gaussdb_m_mode: false,
|
is_gaussdb_m_mode: false,
|
||||||
|
table_collation: None,
|
||||||
});
|
});
|
||||||
assert_eq!(result.warnings, Vec::<String>::new());
|
assert_eq!(result.warnings, Vec::<String>::new());
|
||||||
assert_eq!(result.statements.len(), 4);
|
assert_eq!(result.statements.len(), 4);
|
||||||
|
|||||||
Reference in new issue
Block a user