feat(sqlite): support autoincrement primary key in structure editor

This commit is contained in:
二丫讲梵
2026-09-13 02:11:17 +08:00
committed by GitHub
parent b8f39f9c24
commit 3767f4a83b
9 changed files with 285 additions and 13 deletions
@@ -1153,6 +1153,18 @@ function setMysqlAutoIncrement(column: EditableStructureColumn, checked: boolean
void loadMysqlAutoIncrementCounter(true);
}
}
function isSqliteAutoIncrement(column: EditableStructureColumn): boolean {
return structureDialect.value === "sqlite" && column.isPrimaryKey && isSqliteIntegerType(column.dataType) && column.extra.autoIncrement === true;
}
function canEditSqliteAutoIncrement(column: EditableStructureColumn): boolean {
return structureDialect.value === "sqlite" && column.isPrimaryKey && isSqliteIntegerType(column.dataType) && !columns.value.some((candidate) => candidate !== column && candidate.isPrimaryKey && candidate.extra.autoIncrement);
}
function setSqliteAutoIncrement(column: EditableStructureColumn, checked: boolean) {
column.extra.autoIncrement = checked;
}
function isSqliteIntegerType(dataType: string): boolean {
return /^(integer|int|tinyint|smallint|mediumint|bigint)$/i.test(dataType.trim().split("(")[0]);
}
function onMysqlAutoIncrementInput(event: Event) {
const input = event.target as HTMLInputElement;
if (/^\d*$/.test(input.value)) {
@@ -4698,6 +4710,12 @@ watch(
</template>
</template>
<!-- MySQL: AUTO_INCREMENT + ON UPDATE CURRENT_TIMESTAMP -->
<template v-else-if="structureDialect === 'sqlite'">
<label :class="[structurePropertyLabelClass, 'shrink-0 pr-1']" :title="t('structureEditor.autoIncrement')">
<input :checked="isSqliteAutoIncrement(column)" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" :disabled="!canEditSqliteAutoIncrement(column)" @change="setSqliteAutoIncrement(column, ($event.target as HTMLInputElement).checked)" />
<span>{{ t("structureEditor.autoIncrement") }}</span>
</label>
</template>
<template v-else-if="structureDialect === 'mysql'">
<label :class="[structurePropertyLabelClass, 'shrink-0 pr-1']" :title="t('structureEditor.autoIncrement')">
<input :checked="column.extra.autoIncrement" type="checkbox" :class="[structureCheckboxClass, 'shrink-0']" @change="setMysqlAutoIncrement(column, ($event.target as HTMLInputElement).checked)" />
@@ -635,6 +635,7 @@ export const SQLSERVER_TYPE_LENGTH_DISABLES: string[] = ["bigint", "bit", "date"
export function supportsTableStructureExtendedProperties(databaseType?: DatabaseType): boolean {
return (
databaseType === "mysql" ||
databaseType === "sqlite" ||
databaseType === "dameng" ||
databaseType === "manticoresearch" ||
databaseType === "sqlserver" ||
@@ -654,11 +655,11 @@ export function parseExtraToColumnExtra(extra: string | null | undefined, databa
const lower = extra.toLowerCase().trim();
if (!lower) return result;
if (databaseType === "mysql") {
if (lower.includes("auto_increment")) {
if (databaseType === "mysql" || databaseType === "sqlite") {
if (lower.includes("auto_increment") || lower.includes("autoincrement")) {
result.autoIncrement = true;
}
if (lower.includes("on update current_timestamp")) {
if (databaseType === "mysql" && lower.includes("on update current_timestamp")) {
result.onUpdateCurrentTimestamp = true;
}
} else if (databaseType === "postgres" || databaseType === "gaussdb" || databaseType === "kwdb" || databaseType === "questdb" || databaseType === "highgo" || databaseType === "uxdb" || databaseType === "vastbase" || databaseType === "kingbase") {
+133 -2
View File
@@ -5104,7 +5104,14 @@ fn generate_create_table_sql(
continue;
}
AutoIncColumnBuild::AppendSuffix { suffix, skip_default, postgres_sequence } => {
let mut def = format!("{} {}", col_name, mapped_type);
// SQLite accepts AUTOINCREMENT only on an exact INTEGER
// PRIMARY KEY, so integer aliases must be normalized here too.
let effective_type = if db_type == DatabaseType::Sqlite && suffix.contains("AUTOINCREMENT") {
"INTEGER"
} else {
mapped_type.as_str()
};
let mut def = format!("{} {}", col_name, effective_type);
if !col.is_nullable {
def.push_str(" NOT NULL");
}
@@ -5127,6 +5134,12 @@ fn generate_create_table_sql(
}
}
if !suffix.is_empty() {
// SQLite requires AUTOINCREMENT to be part of an inline
// INTEGER PRIMARY KEY declaration; it cannot be combined
// with the table-level PRIMARY KEY clause below.
if db_type == DatabaseType::Sqlite && col.is_primary_key && suffix.contains("AUTOINCREMENT") {
def.push_str(" PRIMARY KEY");
}
def.push_str(suffix);
}
if postgres_sequence {
@@ -5134,7 +5147,7 @@ fn generate_create_table_sql(
auto_col_name = Some(col.name.clone());
}
col_defs.push(def);
if col.is_primary_key {
if col.is_primary_key && !(db_type == DatabaseType::Sqlite && suffix.contains("AUTOINCREMENT")) {
pk_cols.push(quote_id(&col.name, db_type));
}
}
@@ -6563,6 +6576,124 @@ mod tests {
assert!(sql.contains("INT"), "integer→INT: {sql}");
}
#[test]
fn sync_to_sqlite_keeps_plain_integer_pk_without_autoincrement() {
let columns = vec![ColumnDiff {
diff_type: "added".into(),
name: "id".into(),
source: Some(ColumnInfo {
name: "id".into(),
data_type: "int".into(),
is_nullable: false,
is_primary_key: true,
..Default::default()
}),
target: None,
changes: vec![],
add_position: None,
}];
let (sql, missing) = generate_create_table_sql(
"t",
&columns,
&[],
&[],
None,
DatabaseType::Sqlite,
None,
Some(DialectKind::Mysql),
&[],
&[],
);
assert!(missing.is_empty(), "{missing:?}");
assert!(!sql.contains("AUTOINCREMENT"), "plain integer PK must not gain AUTOINCREMENT: {sql}");
assert!(sql.contains("PRIMARY KEY"), "table-level PK must be preserved: {sql}");
}
#[test]
fn sync_to_sqlite_composite_integer_pk_stays_valid() {
let columns = vec![
ColumnDiff {
diff_type: "added".into(),
name: "a".into(),
source: Some(ColumnInfo {
name: "a".into(),
data_type: "int".into(),
is_nullable: false,
is_primary_key: true,
..Default::default()
}),
target: None,
changes: vec![],
add_position: None,
},
ColumnDiff {
diff_type: "added".into(),
name: "b".into(),
source: Some(ColumnInfo {
name: "b".into(),
data_type: "int".into(),
is_nullable: false,
is_primary_key: true,
..Default::default()
}),
target: None,
changes: vec![],
add_position: None,
},
];
let (sql, missing) = generate_create_table_sql(
"t",
&columns,
&[],
&[],
None,
DatabaseType::Sqlite,
None,
Some(DialectKind::Mysql),
&[],
&[],
);
assert!(missing.is_empty(), "{missing:?}");
assert!(!sql.contains("AUTOINCREMENT"), "composite PK must not gain AUTOINCREMENT: {sql}");
assert!(sql.matches("PRIMARY KEY").count() == 1, "single table-level PK expected: {sql}");
}
#[test]
fn sync_to_sqlite_explicit_autoincrement_preserved_and_normalized() {
let columns = vec![ColumnDiff {
diff_type: "added".into(),
name: "id".into(),
source: Some(ColumnInfo {
name: "id".into(),
data_type: "bigint".into(),
is_nullable: false,
is_primary_key: true,
extra: Some("auto_increment".to_string()),
..Default::default()
}),
target: None,
changes: vec![],
add_position: None,
}];
let (sql, missing) = generate_create_table_sql(
"t",
&columns,
&[],
&[],
None,
DatabaseType::Sqlite,
None,
Some(DialectKind::Mysql),
&[],
&[],
);
assert!(missing.is_empty(), "{missing:?}");
assert!(sql.contains("PRIMARY KEY AUTOINCREMENT"), "explicit auto-increment preserved: {sql}");
assert!(sql.contains("INTEGER"), "bigint must normalize to exact INTEGER: {sql}");
assert!(!sql.contains("BIGINT"), "bigint must normalize to exact INTEGER: {sql}");
assert!(!sql.contains("PRIMARY KEY (\"id\")"), "no duplicate table-level PK: {sql}");
}
// -- 5. MySQL → SQLite type conversion --
#[test]
fn mysql_to_sqlite_type_conversion() {
@@ -539,7 +539,7 @@ fn sqlite_family(db: DatabaseType) -> DdlDialectProfile {
DdlDialectProfile {
database_type: db,
quote: QuoteStyle::DoubleQuote,
auto_inc: AutoIncSyntax::None,
auto_inc: AutoIncSyntax::Suffix(" AUTOINCREMENT"),
supports_display_width: false,
requires_explicit_varchar_length: false,
max_varchar_len: None,
@@ -380,7 +380,10 @@ pub fn rewrite_column_type(source_type: &str, target: DatabaseType, source_diale
pub fn column_is_auto_increment(col: &ColumnInfo) -> bool {
col.extra.as_deref().is_some_and(|extra| {
let lower = extra.to_ascii_lowercase();
lower.contains("auto_increment") || lower.contains("identity") || lower.contains("serial")
lower.contains("auto_increment")
|| lower.contains("autoincrement")
|| lower.contains("identity")
|| lower.contains("serial")
})
}
@@ -405,7 +408,13 @@ pub fn apply_auto_inc_to_column_def(
col: &ColumnInfo,
is_integer_like: bool,
) -> AutoIncColumnBuild {
let wants_auto = col.is_primary_key && (column_is_auto_increment(col) || is_integer_like);
let wants_auto = col.is_primary_key
&& (column_is_auto_increment(col)
|| (is_integer_like
&& !matches!(
profile.auto_inc,
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(" AUTOINCREMENT")
)));
match profile.auto_inc {
crate::sql_dialect::ddl_profile::AutoIncSyntax::ReplaceTypeWith(type_name)
@@ -417,7 +426,12 @@ pub fn apply_auto_inc_to_column_def(
}
AutoIncColumnBuild::Complete { def, skip_default: true }
}
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(suffix) if col.is_primary_key && is_integer_like => {
// SQLite's AUTOINCREMENT changes semantics (sqlite_sequence, no rowid
// reuse), so it must only be emitted for sources that explicitly ask
// for auto-increment; other suffixes keep the implicit PK heuristic.
crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(suffix)
if col.is_primary_key && is_integer_like && (wants_auto || suffix != " AUTOINCREMENT") =>
{
AutoIncColumnBuild::AppendSuffix { suffix, skip_default: false, postgres_sequence: false }
}
crate::sql_dialect::ddl_profile::AutoIncSyntax::PostgresSequence if col.is_primary_key && is_integer_like => {
@@ -984,10 +984,23 @@ pub(super) fn build_sqlite_existing_column_sql(
return Vec::new();
};
let mut statements = Vec::new();
// Tri-state: an unset auto_increment flag inherits the original value; only an
// explicit toggle (SQLite cannot add or drop AUTOINCREMENT without a rebuild)
// counts as an unsupported change.
let original_auto_increment = original.extra.as_deref().is_some_and(|extra| {
let lower = extra.to_ascii_lowercase();
lower.contains("auto_increment")
|| lower.contains("autoincrement")
|| lower.contains("identity")
|| lower.contains("serial")
});
let auto_increment_changed =
column.extra.as_ref().and_then(|e| e.auto_increment).is_some_and(|value| value != original_auto_increment);
let unsupported_change = column.data_type.trim() != original.data_type.trim()
|| column.is_nullable != original.is_nullable
|| normalize_default(Some(&column.default_value)) != original_default(column)
|| clean(&column.comment) != original_comment(column);
|| clean(&column.comment) != original_comment(column)
|| auto_increment_changed;
if column.name != original.name {
statements.push(format!(
"ALTER TABLE {table} RENAME COLUMN {} TO {};",
@@ -113,6 +113,13 @@ pub(super) fn original_is_mysql_generated_column(column: &EditableStructureColum
pub(super) fn column_extra_clause(dialect: StructureDialect, column: &EditableStructureColumn) -> Option<String> {
let extra = column.extra.as_ref()?;
match dialect {
StructureDialect::Sqlite => {
if extra.auto_increment.unwrap_or(false) && column.is_primary_key {
Some("AUTOINCREMENT".to_string())
} else {
None
}
}
StructureDialect::Mysql => {
let mut clauses = Vec::new();
if extra.auto_increment.unwrap_or(false) {
@@ -16,6 +16,13 @@ use super::util::{
use super::validation::{validate_columns, validate_concurrent_index_scope, validate_dameng_identity};
use crate::models::connection::DatabaseType;
fn is_sqlite_integer_family_type(data_type: &str) -> bool {
let normalized = data_type.trim().to_ascii_lowercase();
["int", "integer", "tinyint", "smallint", "mediumint", "bigint"]
.iter()
.any(|candidate| normalized == *candidate || normalized.starts_with(&format!("{candidate}(")))
}
pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStructureSqlResult {
let capabilities;
let dialect;
@@ -60,11 +67,40 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
return TableStructureSqlResult { statements: Vec::new(), warnings };
}
}
if dialect == StructureDialect::Sqlite {
let primary_key_count = active_columns.iter().filter(|column| column.is_primary_key).count();
for column in &active_columns {
if !column.is_primary_key || !column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false)) {
continue;
}
if primary_key_count != 1 {
warnings.push(
"SQLite AUTOINCREMENT requires a single INTEGER PRIMARY KEY column; disable auto-increment on composite primary keys.".to_string(),
);
} else if !is_sqlite_integer_family_type(&column.data_type) {
warnings.push(format!(
"SQLite auto-increment column \"{}\" must use an integer type (normalized to INTEGER).",
column.name
));
}
}
if !warnings.is_empty() {
return TableStructureSqlResult { statements: Vec::new(), warnings };
}
}
let mut statements = Vec::new();
let mut column_definitions = Vec::new();
for column in &active_columns {
let data_type = column_data_type(dialect, column);
let mut data_type = column_data_type(dialect, column);
// SQLite accepts AUTOINCREMENT only on an exact INTEGER PRIMARY KEY,
// so integer-family aliases are normalized when auto-increment is on.
if dialect == StructureDialect::Sqlite
&& column.is_primary_key
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false))
{
data_type = "INTEGER".to_string();
}
let mut parts = vec![quote_new_ident(options.database_type, dialect, &column.name), data_type];
if options.database_type == Some(DatabaseType::Mysql) && is_mysql_character_data_type(&column.data_type) {
if !column.character_set.trim().is_empty() {
@@ -74,7 +110,12 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
parts.push(format!("COLLATE {}", quote_ident(dialect, &column.collation)));
}
}
if !column.is_nullable
if dialect == StructureDialect::Sqlite
&& column.is_primary_key
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false))
{
parts.push("PRIMARY KEY".to_string());
} else if !column.is_nullable
&& !column.is_primary_key
&& !matches!(dialect, StructureDialect::ClickHouse | StructureDialect::ManticoreSearch)
{
@@ -106,7 +147,12 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
let pk_columns: Vec<_> = active_columns
.iter()
.filter(|column| column.is_primary_key && dialect != StructureDialect::ManticoreSearch)
.filter(|column| {
column.is_primary_key
&& dialect != StructureDialect::ManticoreSearch
&& !(dialect == StructureDialect::Sqlite
&& column.extra.as_ref().is_some_and(|e| e.auto_increment.unwrap_or(false)))
})
.collect();
if !pk_columns.is_empty() {
let pk_list = pk_columns
@@ -78,6 +78,48 @@ fn mysql_table_engine_change_generates_alter_table() {
assert_eq!(result.statements, vec!["ALTER TABLE `remote_orders` ENGINE = FEDERATED;"]);
}
#[test]
fn sqlite_autoincrement_normalizes_integer_aliases_to_exact_integer() {
let mut id = column("id");
id.data_type = "bigint".to_string();
id.is_nullable = false;
id.is_primary_key = true;
id.extra = Some(ColumnExtra { auto_increment: Some(true), ..Default::default() });
let options = structure_change_options(DatabaseType::Sqlite, None, "items", vec![id, column("value")]);
let result = build_create_table_sql(options);
assert!(result.warnings.is_empty(), "{:?}", result.warnings);
assert_eq!(
result.statements[0],
"CREATE TABLE \"items\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\n \"value\" varchar(255)\n);"
);
}
#[test]
fn sqlite_autoincrement_rejects_composite_primary_keys_and_non_integer_types() {
let mut a = column("a");
a.data_type = "int".to_string();
a.is_primary_key = true;
a.extra = Some(ColumnExtra { auto_increment: Some(true), ..Default::default() });
let mut b = column("b");
b.data_type = "int".to_string();
b.is_primary_key = true;
let options = structure_change_options(DatabaseType::Sqlite, None, "items", vec![a, b]);
let result = build_create_table_sql(options);
assert!(result.statements.is_empty());
assert!(result.warnings.iter().any(|w| w.contains("composite primary keys")), "{:?}", result.warnings);
let mut text_id = column("id");
text_id.data_type = "text".to_string();
text_id.is_primary_key = true;
text_id.extra = Some(ColumnExtra { auto_increment: Some(true), ..Default::default() });
let options = structure_change_options(DatabaseType::Sqlite, None, "items2", vec![text_id]);
let result = build_create_table_sql(options);
assert!(result.statements.is_empty());
assert!(result.warnings.iter().any(|w| w.contains("must use an integer type")), "{:?}", result.warnings);
}
#[test]
fn mysql_create_table_includes_engine_before_comment() {
let mut options = structure_change_options(DatabaseType::Mysql, Some("dbx_test"), "archive", vec![column("id")]);