Skip to content

Commit 75d3605

Browse files
sj-robertclaude
authored andcommitted
fix(liquibase): validate required XML attributes before generating SQL
The Liquibase XML fallback parser silently generated malformed SQL (e.g. `ALTER TABLE ADD CONSTRAINT FOREIGN KEY () REFERENCES ()`) when required attributes were missing. Now validates and warns+skips instead. Also fixes addForeignKeyConstraint to omit the CONSTRAINT keyword when constraintName is absent, matching the pattern used by addPrimaryKey and addUniqueConstraint. Affected handlers: dropIndex, addForeignKeyConstraint, addPrimaryKey, addUniqueConstraint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ce982e8 commit 75d3605

1 file changed

Lines changed: 236 additions & 25 deletions

File tree

src/input/liquibase_xml.rs

Lines changed: 236 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -328,30 +328,83 @@ fn handle_start_tag(
328328
Ok(ParseState::InChangeSet(cs))
329329
}
330330
"dropIndex" => {
331-
let index_name = get_attr(attrs, "indexName").unwrap_or_default();
331+
let Some(index_name) = get_attr(attrs, "indexName") else {
332+
eprintln!(
333+
"Warning: <dropIndex> missing required 'indexName' attribute, skipping"
334+
);
335+
return Ok(ParseState::InChangeSet(cs));
336+
};
332337
let schema_name = get_attr(attrs, "schemaName");
333338
let qualified = qualify_name(&schema_name, &index_name);
334339
cs.sql_parts.push(format!("DROP INDEX {};", qualified));
335340
Ok(ParseState::InChangeSet(cs))
336341
}
337342
"addForeignKeyConstraint" => {
338-
let sql = generate_add_fk_sql(attrs);
343+
let Some(base_table) = get_attr(attrs, "baseTableName") else {
344+
eprintln!(
345+
"Warning: <addForeignKeyConstraint> missing required 'baseTableName' attribute, skipping"
346+
);
347+
return Ok(ParseState::InChangeSet(cs));
348+
};
349+
let Some(ref_table) = get_attr(attrs, "referencedTableName") else {
350+
eprintln!(
351+
"Warning: <addForeignKeyConstraint> missing required 'referencedTableName' attribute, skipping"
352+
);
353+
return Ok(ParseState::InChangeSet(cs));
354+
};
355+
let Some(base_columns) = get_attr(attrs, "baseColumnNames") else {
356+
eprintln!(
357+
"Warning: <addForeignKeyConstraint> missing required 'baseColumnNames' attribute, skipping"
358+
);
359+
return Ok(ParseState::InChangeSet(cs));
360+
};
361+
let Some(ref_columns) = get_attr(attrs, "referencedColumnNames") else {
362+
eprintln!(
363+
"Warning: <addForeignKeyConstraint> missing required 'referencedColumnNames' attribute, skipping"
364+
);
365+
return Ok(ParseState::InChangeSet(cs));
366+
};
367+
let sql = generate_add_fk_sql(
368+
attrs,
369+
&base_table,
370+
&base_columns,
371+
&ref_table,
372+
&ref_columns,
373+
);
339374
cs.sql_parts.push(sql);
340375
Ok(ParseState::InChangeSet(cs))
341376
}
342377
"addPrimaryKey" => {
343-
if get_attr(attrs, "tableName").is_none() {
378+
let Some(table_name) = get_attr(attrs, "tableName") else {
344379
eprintln!(
345380
"Warning: <addPrimaryKey> missing required 'tableName' attribute, skipping"
346381
);
347382
return Ok(ParseState::InChangeSet(cs));
348-
}
349-
let sql = generate_add_pk_sql(attrs);
383+
};
384+
let Some(column_names) = get_attr(attrs, "columnNames") else {
385+
eprintln!(
386+
"Warning: <addPrimaryKey> missing required 'columnNames' attribute, skipping"
387+
);
388+
return Ok(ParseState::InChangeSet(cs));
389+
};
390+
let sql = generate_add_pk_sql(attrs, &table_name, &column_names);
350391
cs.sql_parts.push(sql);
351392
Ok(ParseState::InChangeSet(cs))
352393
}
353394
"addUniqueConstraint" => {
354-
let sql = generate_add_unique_sql(attrs);
395+
let Some(table_name) = get_attr(attrs, "tableName") else {
396+
eprintln!(
397+
"Warning: <addUniqueConstraint> missing required 'tableName' attribute, skipping"
398+
);
399+
return Ok(ParseState::InChangeSet(cs));
400+
};
401+
let Some(column_names) = get_attr(attrs, "columnNames") else {
402+
eprintln!(
403+
"Warning: <addUniqueConstraint> missing required 'columnNames' attribute, skipping"
404+
);
405+
return Ok(ParseState::InChangeSet(cs));
406+
};
407+
let sql = generate_add_unique_sql(attrs, &table_name, &column_names);
355408
cs.sql_parts.push(sql);
356409
Ok(ParseState::InChangeSet(cs))
357410
}
@@ -689,32 +742,51 @@ fn generate_create_index_sql(ci: &CreateIndexState) -> String {
689742
}
690743

691744
/// Generate an ALTER TABLE ADD CONSTRAINT ... FOREIGN KEY SQL statement.
692-
fn generate_add_fk_sql(attrs: &[(String, String)]) -> String {
745+
///
746+
/// Required fields (`base_table`, `base_columns`, `ref_table`, `ref_columns`)
747+
/// are passed as pre-validated parameters. Optional fields (`constraintName`,
748+
/// schema names) are extracted from `attrs`.
749+
fn generate_add_fk_sql(
750+
attrs: &[(String, String)],
751+
base_table: &str,
752+
base_columns: &str,
753+
ref_table: &str,
754+
ref_columns: &str,
755+
) -> String {
693756
let constraint_name = get_attr(attrs, "constraintName").unwrap_or_default();
694-
let base_table = get_attr(attrs, "baseTableName").unwrap_or_default();
695757
let base_schema = get_attr(attrs, "baseTableSchemaName");
696-
let base_columns = get_attr(attrs, "baseColumnNames").unwrap_or_default();
697-
let ref_table = get_attr(attrs, "referencedTableName").unwrap_or_default();
698758
let ref_schema = get_attr(attrs, "referencedTableSchemaName");
699-
let ref_columns = get_attr(attrs, "referencedColumnNames").unwrap_or_default();
700759

701-
let base_qualified = qualify_name(&base_schema, &base_table);
702-
let ref_qualified = qualify_name(&ref_schema, &ref_table);
760+
let base_qualified = qualify_name(&base_schema, base_table);
761+
let ref_qualified = qualify_name(&ref_schema, ref_table);
703762

704-
format!(
705-
"ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({});",
706-
base_qualified, constraint_name, base_columns, ref_qualified, ref_columns
707-
)
763+
if constraint_name.is_empty() {
764+
format!(
765+
"ALTER TABLE {} ADD FOREIGN KEY ({}) REFERENCES {} ({});",
766+
base_qualified, base_columns, ref_qualified, ref_columns
767+
)
768+
} else {
769+
format!(
770+
"ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({});",
771+
base_qualified, constraint_name, base_columns, ref_qualified, ref_columns
772+
)
773+
}
708774
}
709775

710776
/// Generate an ALTER TABLE ADD CONSTRAINT ... PRIMARY KEY SQL statement.
711-
fn generate_add_pk_sql(attrs: &[(String, String)]) -> String {
777+
///
778+
/// Required fields (`table_name`, `column_names`) are passed as pre-validated
779+
/// parameters. Optional fields (`constraintName`, `schemaName`) are extracted
780+
/// from `attrs`.
781+
fn generate_add_pk_sql(
782+
attrs: &[(String, String)],
783+
table_name: &str,
784+
column_names: &str,
785+
) -> String {
712786
let constraint_name = get_attr(attrs, "constraintName").unwrap_or_default();
713-
let table_name = get_attr(attrs, "tableName").unwrap_or_default();
714787
let schema_name = get_attr(attrs, "schemaName");
715-
let column_names = get_attr(attrs, "columnNames").unwrap_or_default();
716788

717-
let qualified = qualify_name(&schema_name, &table_name);
789+
let qualified = qualify_name(&schema_name, table_name);
718790

719791
if constraint_name.is_empty() {
720792
format!(
@@ -730,13 +802,19 @@ fn generate_add_pk_sql(attrs: &[(String, String)]) -> String {
730802
}
731803

732804
/// Generate an ALTER TABLE ADD CONSTRAINT ... UNIQUE SQL statement.
733-
fn generate_add_unique_sql(attrs: &[(String, String)]) -> String {
805+
///
806+
/// Required fields (`table_name`, `column_names`) are passed as pre-validated
807+
/// parameters. Optional fields (`constraintName`, `schemaName`) are extracted
808+
/// from `attrs`.
809+
fn generate_add_unique_sql(
810+
attrs: &[(String, String)],
811+
table_name: &str,
812+
column_names: &str,
813+
) -> String {
734814
let constraint_name = get_attr(attrs, "constraintName").unwrap_or_default();
735-
let table_name = get_attr(attrs, "tableName").unwrap_or_default();
736815
let schema_name = get_attr(attrs, "schemaName");
737-
let column_names = get_attr(attrs, "columnNames").unwrap_or_default();
738816

739-
let qualified = qualify_name(&schema_name, &table_name);
817+
let qualified = qualify_name(&schema_name, table_name);
740818

741819
if constraint_name.is_empty() {
742820
format!("ALTER TABLE {} ADD UNIQUE ({});", qualified, column_names)
@@ -1388,6 +1466,139 @@ mod tests {
13881466
assert!(units[0].run_in_transaction);
13891467
}
13901468

1469+
#[test]
1470+
fn test_add_fk_without_constraint_name() {
1471+
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1472+
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
1473+
<changeSet id="1" author="dev">
1474+
<addForeignKeyConstraint
1475+
baseTableName="orders"
1476+
baseColumnNames="user_id"
1477+
referencedTableName="users"
1478+
referencedColumnNames="id"/>
1479+
</changeSet>
1480+
</databaseChangeLog>"#;
1481+
1482+
let units = parse_changelog_xml(xml, Path::new("test.xml"))
1483+
.expect("Should parse addForeignKeyConstraint without constraintName");
1484+
assert_eq!(units.len(), 1);
1485+
let sql = &units[0].sql;
1486+
assert_eq!(
1487+
sql,
1488+
"ALTER TABLE orders ADD FOREIGN KEY (user_id) REFERENCES users (id);",
1489+
"Expected valid FK SQL without CONSTRAINT keyword, got: {}",
1490+
sql
1491+
);
1492+
}
1493+
1494+
#[test]
1495+
fn test_drop_index_missing_index_name() {
1496+
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1497+
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
1498+
<changeSet id="1" author="dev">
1499+
<dropIndex/>
1500+
<sql>SELECT 1;</sql>
1501+
</changeSet>
1502+
</databaseChangeLog>"#;
1503+
1504+
let units = parse_changelog_xml(xml, Path::new("test.xml"))
1505+
.expect("Should handle dropIndex without indexName");
1506+
assert_eq!(units.len(), 1);
1507+
let sql = &units[0].sql;
1508+
assert!(
1509+
!sql.contains("DROP INDEX"),
1510+
"Expected no DROP INDEX for missing indexName, got: {}",
1511+
sql
1512+
);
1513+
assert!(
1514+
sql.contains("SELECT 1;"),
1515+
"Expected subsequent SQL to still be processed, got: {}",
1516+
sql
1517+
);
1518+
}
1519+
1520+
#[test]
1521+
fn test_add_fk_missing_base_table_name() {
1522+
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1523+
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
1524+
<changeSet id="1" author="dev">
1525+
<addForeignKeyConstraint
1526+
constraintName="fk_test"
1527+
baseColumnNames="user_id"
1528+
referencedTableName="users"
1529+
referencedColumnNames="id"/>
1530+
<sql>SELECT 1;</sql>
1531+
</changeSet>
1532+
</databaseChangeLog>"#;
1533+
1534+
let units = parse_changelog_xml(xml, Path::new("test.xml"))
1535+
.expect("Should handle addForeignKeyConstraint without baseTableName");
1536+
assert_eq!(units.len(), 1);
1537+
let sql = &units[0].sql;
1538+
assert!(
1539+
!sql.contains("FOREIGN KEY"),
1540+
"Expected no FK SQL for missing baseTableName, got: {}",
1541+
sql
1542+
);
1543+
assert!(
1544+
sql.contains("SELECT 1;"),
1545+
"Expected subsequent SQL to still be processed, got: {}",
1546+
sql
1547+
);
1548+
}
1549+
1550+
#[test]
1551+
fn test_add_primary_key_missing_column_names() {
1552+
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1553+
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
1554+
<changeSet id="1" author="dev">
1555+
<addPrimaryKey tableName="orders"/>
1556+
<sql>SELECT 1;</sql>
1557+
</changeSet>
1558+
</databaseChangeLog>"#;
1559+
1560+
let units = parse_changelog_xml(xml, Path::new("test.xml"))
1561+
.expect("Should handle addPrimaryKey without columnNames");
1562+
assert_eq!(units.len(), 1);
1563+
let sql = &units[0].sql;
1564+
assert!(
1565+
!sql.contains("PRIMARY KEY"),
1566+
"Expected no PRIMARY KEY SQL for missing columnNames, got: {}",
1567+
sql
1568+
);
1569+
assert!(
1570+
sql.contains("SELECT 1;"),
1571+
"Expected subsequent SQL to still be processed, got: {}",
1572+
sql
1573+
);
1574+
}
1575+
1576+
#[test]
1577+
fn test_add_unique_constraint_missing_table_name() {
1578+
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1579+
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
1580+
<changeSet id="1" author="dev">
1581+
<addUniqueConstraint columnNames="email" constraintName="uq_test"/>
1582+
<sql>SELECT 1;</sql>
1583+
</changeSet>
1584+
</databaseChangeLog>"#;
1585+
1586+
let units = parse_changelog_xml(xml, Path::new("test.xml"))
1587+
.expect("Should handle addUniqueConstraint without tableName");
1588+
assert_eq!(units.len(), 1);
1589+
let sql = &units[0].sql;
1590+
assert!(
1591+
!sql.contains("UNIQUE"),
1592+
"Expected no UNIQUE SQL for missing tableName, got: {}",
1593+
sql
1594+
);
1595+
assert!(
1596+
sql.contains("SELECT 1;"),
1597+
"Expected subsequent SQL to still be processed, got: {}",
1598+
sql
1599+
);
1600+
}
1601+
13911602
#[test]
13921603
fn test_malformed_xml_returns_no_units() {
13931604
// Streaming XML parsers (quick-xml) don't necessarily error on truncated input;

0 commit comments

Comments
 (0)