mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(sqlite): support autoincrement primary key in structure editor
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
Reference in New Issue
Block a user