fix(schema-diff): require explicit expression index provenance
This commit is contained in:
1 parent
01ea889569
commit
4660a11f25
28 files changed
+673
-39
No files matched your search
@@ -132,6 +132,55 @@ fn mysql_index_column_sql(column: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_index_column_sql(column: &str, is_expression: bool) -> String {
|
||||
// Expression/functional index key parts arrive as raw expression text, not a plain
|
||||
// column name; quoting the whole expression as an identifier turns it into a literal
|
||||
// column reference that doesn't exist (#6295).
|
||||
if is_expression {
|
||||
column.trim().to_string()
|
||||
} else {
|
||||
quote_ident(StructureDialect::Postgres, column)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-key expression provenance for `columns` (the edited/current key list), computed once for
|
||||
/// the whole index. The editor only lets users toggle real table columns onto an index (see
|
||||
/// TableStructureEditor.vue's column checkbox list) — there is no free-text expression input —
|
||||
/// so an edited key part is only ever an expression when it corresponds to one from the original
|
||||
/// introspected snapshot.
|
||||
///
|
||||
/// Matches each edited key part against the first *not yet claimed* original key part with the
|
||||
/// same text, rather than the first textual match overall: a real column whose name happens to
|
||||
/// equal another key's expression text (or two key parts with identical text) can otherwise steal
|
||||
/// the wrong original's provenance. Consuming each original slot at most once keeps provenance
|
||||
/// tied to its true ordinal position instead of being re-derived by scanning for a text match
|
||||
/// (#6312 review). A key without explicit provenance is treated as a real column: the editor can
|
||||
/// only add table columns, and guessing from the identifier text breaks valid quoted names.
|
||||
fn key_expression_flags(index: &EditableStructureIndex, columns: &[String]) -> Vec<bool> {
|
||||
let original = match &index.original {
|
||||
Some(original) if !original.key_is_expression.is_empty() => original,
|
||||
_ => return vec![false; columns.len()],
|
||||
};
|
||||
let mut consumed = vec![false; original.columns.len()];
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let claimed = original
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, original_column)| !consumed[*i] && *original_column == column);
|
||||
match claimed {
|
||||
Some((i, _)) => {
|
||||
consumed[i] = true;
|
||||
original.key_is_expression.get(i).copied().unwrap_or(false)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_drop_index_sql(
|
||||
database_type: Option<DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
@@ -179,11 +228,15 @@ pub(super) fn build_create_index_statements(
|
||||
|
||||
let unique = if index.is_unique { "UNIQUE " } else { "" };
|
||||
let replace = if or_replace { "OR REPLACE " } else { "" };
|
||||
let key_is_expression = key_expression_flags(index, &columns);
|
||||
let cols = columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
.enumerate()
|
||||
.map(|(i, column)| {
|
||||
if dialect == StructureDialect::Mysql {
|
||||
mysql_index_column_sql(column)
|
||||
} else if dialect == StructureDialect::Postgres {
|
||||
postgres_index_column_sql(column, key_is_expression[i])
|
||||
} else {
|
||||
quote_ident(dialect, column)
|
||||
}
|
||||
|
||||
@@ -2018,6 +2018,7 @@ mod tests {
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
}),
|
||||
marked_for_drop: true,
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ fn existing_index(name: &str, columns: &[&str], is_unique: bool) -> EditableStru
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
});
|
||||
index
|
||||
}
|
||||
@@ -169,6 +170,7 @@ fn builds_mysql_column_and_index_changes() {
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
});
|
||||
let mut email_index = index("uniq_users_email", &["email"]);
|
||||
email_index.is_unique = true;
|
||||
@@ -741,6 +743,7 @@ fn builds_informix_column_and_index_changes() {
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
});
|
||||
let mut email_index = index("uniq_users_email", &["email"]);
|
||||
email_index.is_unique = true;
|
||||
@@ -939,6 +942,7 @@ fn iris_drop_index_includes_table_name() {
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -1218,6 +1222,7 @@ fn gbase8a_uses_limited_mysql_ddl() {
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -1632,6 +1637,74 @@ fn builds_postgres_create_table_with_comments_and_index() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_expression_like_new_index_columns_without_provenance() {
|
||||
let expression_like_column = "COALESCE(height, '-1'::integer::double precision)";
|
||||
let idx = index("idx_expression_like_column", &[expression_like_column]);
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Kingbase),
|
||||
schema: Some("public".to_string()),
|
||||
table_name: "tankong_data".to_string(),
|
||||
columns: vec![column(expression_like_column)],
|
||||
indexes: vec![idx],
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert!(result.statements.iter().any(|statement| statement.contains(&format!("(\"{expression_like_column}\")"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_key_provenance_when_rebuilding_an_untouched_postgres_index() {
|
||||
// PR #6312 review: a quoted column identifier can legitimately contain whitespace, `(`,
|
||||
// or `::` (e.g. PostgreSQL metadata returning the ordinary column name `order item`
|
||||
// through a.attname). Regenerating an *unedited* existing index (e.g. only its uniqueness
|
||||
// changed) must trust the original snapshot's real per-key provenance rather than guessing
|
||||
// from characters, so a weirdly-named real column stays quoted and only the genuine
|
||||
// expression key part stays bare.
|
||||
let expression_key_part = "COALESCE(height, '-1'::integer::double precision)";
|
||||
let mut changed = existing_index("uq_weird_columns", &["order item", "a(b)", "a::b", expression_key_part], false);
|
||||
changed.is_unique = true;
|
||||
changed.original.as_mut().unwrap().key_is_expression = vec![false, false, false, true];
|
||||
|
||||
let result =
|
||||
build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), changed));
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(result.statements.len(), 2);
|
||||
assert!(result.statements[0].starts_with("DROP INDEX "));
|
||||
assert_eq!(
|
||||
result.statements[1],
|
||||
format!(
|
||||
"CREATE UNIQUE INDEX \"uq_weird_columns\" ON \"public\".\"USERS\" (\"order item\", \"a(b)\", \"a::b\", {expression_key_part});"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_key_provenance_by_ordinal_position_not_first_text_match() {
|
||||
// PR #6312 review (round 2): provenance must stay tied to each key part's original ordinal
|
||||
// slot, not be re-derived by scanning for the first original key part with matching text. Two
|
||||
// key parts sharing identical text with different provenance (a pathological but real case —
|
||||
// e.g. a genuine expression key part and a real column whose name happens to equal that same
|
||||
// text) must not let the first one's provenance leak onto the second.
|
||||
let mut changed = existing_index("idx_dup", &["dup", "dup"], false);
|
||||
changed.is_unique = true;
|
||||
changed.original.as_mut().unwrap().key_is_expression = vec![true, false];
|
||||
|
||||
let result =
|
||||
build_table_structure_change_sql(index_change_options(DatabaseType::Postgres, Some("public"), changed));
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(result.statements.len(), 2);
|
||||
assert!(result.statements[0].starts_with("DROP INDEX "));
|
||||
assert_eq!(result.statements[1], "CREATE UNIQUE INDEX \"idx_dup\" ON \"public\".\"USERS\" (dup, \"dup\");");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_trims_table_name_whitespace_for_all_statements() {
|
||||
let mut id = column("id");
|
||||
@@ -1706,6 +1779,7 @@ fn qualifies_attached_sqlite_table_and_index_changes() {
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
});
|
||||
let email_index = index("idx_users_email", &["email"]);
|
||||
|
||||
@@ -4615,6 +4689,7 @@ fn oscar_drop_index_with_schema_qualifier() {
|
||||
index_type: None,
|
||||
included_columns: None,
|
||||
comment: None,
|
||||
key_is_expression: Vec::new(),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
|
||||
@@ -119,6 +119,10 @@ pub struct IndexInfo {
|
||||
pub included_columns: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
/// Parallel to `columns`: `true` at index `i` means `columns[i]` is a raw expression
|
||||
/// (e.g. sourced from `pg_get_indexdef`), not a plain column name.
|
||||
#[serde(default)]
|
||||
pub key_is_expression: Vec<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
Reference in new issue
Block a user