Skip to main content

pgevolve_core/diff/
tables.rs

1//! Table-level diffing.
2//!
3//! Pairs tables by [`QualifiedName`]. Existence differences emit
4//! [`Change::CreateTable`] / [`Change::DropTable`]; pairs that are present in
5//! both catalogs dispatch to [`super::columns::diff_columns`] and
6//! [`super::constraints::diff_constraints`] and emit a single
7//! [`Change::AlterTable`] containing every per-table operation.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use crate::identifier::{Identifier, QualifiedName};
12use crate::ir::catalog::Catalog;
13use crate::ir::grant::GrantTarget;
14use crate::ir::table::Table;
15
16use super::change::{Change, TableChange};
17use super::changeset::{ChangeSet, RevokeWithOwnerObservation, UnmanagedGrantObservation};
18use super::columns::diff_columns;
19use super::constraints::diff_constraints;
20use super::destructiveness::Destructiveness;
21use super::grants::diff_grants;
22use super::owner_op::{AlterObjectOwner, OwnerObjectKind};
23use super::table_op::TableOpEntry;
24
25/// Diff tables in `target` against `source`, appending entries to `out`.
26#[allow(clippy::too_many_lines)] // exhaustive per-table-property diff; extraction would fragment a single conceptual pass.
27pub fn diff_tables(
28    target: &Catalog,
29    source: &Catalog,
30    out: &mut ChangeSet,
31    managed_roles: &BTreeSet<Identifier>,
32) {
33    let target_map: BTreeMap<&QualifiedName, &Table> =
34        target.tables.iter().map(|t| (&t.qname, t)).collect();
35    let source_map: BTreeMap<&QualifiedName, &Table> =
36        source.tables.iter().map(|t| (&t.qname, t)).collect();
37
38    for (qname, source_table) in &source_map {
39        if !target_map.contains_key(qname) {
40            out.push(
41                Change::CreateTable((*source_table).clone()),
42                Destructiveness::Safe,
43            );
44            // Synthesize an empty target so the attribute helper can diff
45            // source attributes against "nothing" and emit the appropriate
46            // follow-up Changes (owner, grants, policies, storage).
47            let empty_target = Table {
48                qname: source_table.qname.clone(),
49                columns: vec![],
50                constraints: vec![],
51                partition_by: None,
52                partition_of: None,
53                comment: None,
54                owner: None,
55                grants: vec![],
56                rls_enabled: false,
57                rls_forced: false,
58                policies: vec![],
59                storage: crate::ir::reloptions::TableStorageOptions::default(),
60                access_method: None,
61                tablespace: None,
62            };
63            emit_table_attribute_changes(&empty_target, source_table, managed_roles, out);
64        }
65    }
66
67    for (qname, target_table) in &target_map {
68        match source_map.get(qname) {
69            None => {
70                out.push(
71                    Change::DropTable {
72                        qname: (*qname).clone(),
73                        row_count_estimate: None,
74                    },
75                    Destructiveness::RequiresApprovalAndDataLossWarning {
76                        reason: format!("drops table {qname}"),
77                    },
78                );
79            }
80            Some(source_table) => {
81                let mut ops: Vec<TableOpEntry> = Vec::new();
82
83                // Skip column and constraint diffs when either side is a
84                // partition child (`partition_of.is_some()`).
85                //
86                // A partition child's column list is always inherited from the
87                // parent; the canonical source form (`PARTITION OF …`) never
88                // includes explicit columns. Diffing them would produce spurious
89                // ADD/DROP COLUMN steps. Three sub-cases:
90                //
91                //   1. partition_of is changing (None → Some / Some → None):
92                //      ATTACH / DETACH handles column inheritance atomically.
93                //      - ATTACH: child columns must already match parent.
94                //      - DETACH: inherited columns become explicit automatically.
95                //
96                //   2. partition_of is stable (both Some): e.g. Form 3 parses
97                //      a standalone CREATE TABLE + ALTER TABLE ATTACH, giving the
98                //      child an explicit column list; Form 2 gives an empty list.
99                //      Skip to avoid spurious DROP COLUMN steps.
100                //
101                //   3. partition_of is None in both: ordinary table → run diff.
102                let either_is_partition =
103                    source_table.partition_of.is_some() || target_table.partition_of.is_some();
104                if !either_is_partition {
105                    diff_columns(target_table, source_table, &mut ops);
106                    diff_constraints(target_table, source_table, &mut ops);
107                }
108
109                if target_table.comment != source_table.comment {
110                    ops.push(TableOpEntry {
111                        op: super::table_op::TableOp::SetTableComment {
112                            comment: source_table.comment.clone(),
113                        },
114                        destructiveness: Destructiveness::Safe,
115                    });
116                }
117
118                if target_table.tablespace != source_table.tablespace {
119                    let destructiveness = if source_table.partition_by.is_some() {
120                        Destructiveness::Safe
121                    } else {
122                        Destructiveness::RequiresApproval {
123                            reason: "SET TABLESPACE rewrites the table and takes an ACCESS EXCLUSIVE lock".into(),
124                        }
125                    };
126                    ops.push(TableOpEntry {
127                        op: super::table_op::TableOp::SetTableSpace {
128                            name: source_table.tablespace.clone(),
129                        },
130                        destructiveness,
131                    });
132                }
133
134                if !ops.is_empty() {
135                    out.push(
136                        Change::AlterTable {
137                            qname: (*qname).clone(),
138                            ops,
139                        },
140                        Destructiveness::Safe,
141                    );
142                }
143
144                // ---- partition_by diff (parent partitioning configuration) ----
145                // Changing PARTITION BY cannot be done in-place; surface as an
146                // UnsupportedDiff so the ordering phase aborts the plan.
147                match (&source_table.partition_by, &target_table.partition_by) {
148                    (None, None) => {}
149                    (Some(s), Some(t)) if s == t => {}
150                    (Some(_), Some(_)) => {
151                        out.push(
152                            Change::UnsupportedDiff {
153                                reason: format!(
154                                    "cannot change PARTITION BY clause on {qname} in-place; \
155                                     manual migration required"
156                                ),
157                            },
158                            Destructiveness::Safe,
159                        );
160                    }
161                    (None, Some(_)) => {
162                        out.push(
163                            Change::UnsupportedDiff {
164                                reason: format!(
165                                    "cannot remove PARTITION BY from {qname} in-place; \
166                                     manual migration required"
167                                ),
168                            },
169                            Destructiveness::Safe,
170                        );
171                    }
172                    (Some(_), None) => {
173                        out.push(
174                            Change::UnsupportedDiff {
175                                reason: format!(
176                                    "cannot add PARTITION BY to {qname} in-place; \
177                                     manual migration required"
178                                ),
179                            },
180                            Destructiveness::Safe,
181                        );
182                    }
183                }
184
185                // ---- partition_of diff (child membership in partitioned parent) ----
186                match (&source_table.partition_of, &target_table.partition_of) {
187                    (None, None) => {}
188                    (Some(s), Some(t)) if s == t => {}
189                    (Some(s), None) => {
190                        // Source declares partition membership; catalog does not → attach.
191                        out.push(
192                            Change::Table(TableChange::AttachPartition {
193                                parent: s.parent.clone(),
194                                child: (*qname).clone(),
195                                bounds: s.bounds.clone(),
196                            }),
197                            Destructiveness::Safe,
198                        );
199                    }
200                    (None, Some(t)) => {
201                        // Catalog has partition membership; source dropped it → detach.
202                        out.push(
203                            Change::Table(TableChange::DetachPartition {
204                                parent: t.parent.clone(),
205                                child: (*qname).clone(),
206                            }),
207                            Destructiveness::Safe,
208                        );
209                    }
210                    (Some(s), Some(t)) if s.parent != t.parent => {
211                        // Re-parented: detach from old parent, attach to new.
212                        out.push(
213                            Change::Table(TableChange::DetachPartition {
214                                parent: t.parent.clone(),
215                                child: (*qname).clone(),
216                            }),
217                            Destructiveness::Safe,
218                        );
219                        out.push(
220                            Change::Table(TableChange::AttachPartition {
221                                parent: s.parent.clone(),
222                                child: (*qname).clone(),
223                                bounds: s.bounds.clone(),
224                            }),
225                            Destructiveness::Safe,
226                        );
227                    }
228                    (Some(s), Some(_)) => {
229                        // Same parent, bounds differ: detach + re-attach.
230                        out.push(
231                            Change::Table(TableChange::DetachPartition {
232                                parent: s.parent.clone(),
233                                child: (*qname).clone(),
234                            }),
235                            Destructiveness::Safe,
236                        );
237                        out.push(
238                            Change::Table(TableChange::AttachPartition {
239                                parent: s.parent.clone(),
240                                child: (*qname).clone(),
241                                bounds: s.bounds.clone(),
242                            }),
243                            Destructiveness::Safe,
244                        );
245                    }
246                }
247
248                // ---- owner / grants / policies / storage diffs ----
249                emit_table_attribute_changes(target_table, source_table, managed_roles, out);
250            }
251        }
252    }
253}
254
255/// Emit per-attribute diff changes (owner, grants, policies, storage) for
256/// one table pair.
257///
258/// Called from two sites:
259/// - "both catalogs have the table" branch — with the real target table.
260/// - "new table" branch — with a synthesized empty target so the diff against
261///   "nothing" produces one `Change` per non-default attribute the source has.
262///
263/// Intentionally excludes: column/constraint diffs (handled by the ALTER TABLE
264/// ops block), `partition_by`/`partition_of` (structural, inline-with-create),
265/// and comment (rides in the ALTER TABLE ops block above).
266/// Return `grants` with `dropped_columns` removed from every column-level
267/// grant's column list. A column-level grant whose entire list is dropped is
268/// omitted (the column drop alone revokes it); object-level grants
269/// (`columns: None`) and grants on surviving columns pass through unchanged.
270fn strip_dropped_columns_from_grants(
271    grants: &[crate::ir::grant::Grant],
272    dropped_columns: &BTreeSet<&Identifier>,
273) -> Vec<crate::ir::grant::Grant> {
274    if dropped_columns.is_empty() {
275        return grants.to_vec();
276    }
277    grants
278        .iter()
279        .filter_map(|g| {
280            g.columns.as_ref().map_or_else(
281                || Some(g.clone()),
282                |cols| {
283                    let kept: Vec<Identifier> = cols
284                        .iter()
285                        .filter(|c| !dropped_columns.contains(c))
286                        .cloned()
287                        .collect();
288                    if kept.is_empty() {
289                        None
290                    } else {
291                        Some(crate::ir::grant::Grant {
292                            columns: Some(kept),
293                            ..g.clone()
294                        })
295                    }
296                },
297            )
298        })
299        .collect()
300}
301
302/// Emit the GRANT/REVOKE changes for one table pair (object- and column-level),
303/// plus unmanaged-grant and revoke-with-owner observations.
304fn emit_table_grant_changes(
305    target_table: &Table,
306    source_table: &Table,
307    managed_roles: &BTreeSet<Identifier>,
308    out: &mut ChangeSet,
309) {
310    let qname = &source_table.qname;
311    let object_label = format!("table {qname}");
312    // Columns present in the target (live) table but absent from the source are
313    // about to be dropped by an `ALTER TABLE ... DROP COLUMN`. PG cascade-revokes
314    // their column-level ACLs as part of that drop, so they must not generate
315    // `RevokeColumnPrivilege` steps — an explicit `REVOKE ... (dropped_col)`
316    // fails with `42703` once the column is gone. Strip dropped columns from the
317    // target grants before diffing so only surviving-column changes are emitted.
318    let dropped_columns: BTreeSet<&Identifier> = {
319        let source_cols: BTreeSet<&Identifier> =
320            source_table.columns.iter().map(|c| &c.name).collect();
321        target_table
322            .columns
323            .iter()
324            .map(|c| &c.name)
325            .filter(|n| !source_cols.contains(*n))
326            .collect()
327    };
328    let adjusted_target = strip_dropped_columns_from_grants(&target_table.grants, &dropped_columns);
329    let (to_add, to_revoke, unmanaged) =
330        diff_grants(&adjusted_target, &source_table.grants, managed_roles);
331    // Emit REVOKEs before GRANTs (issue #33): revokes must precede grants so
332    // that WGO-change pairs (same grantee+privilege, different wgo) don't
333    // self-cancel (GRANT followed by REVOKE would drop the privilege entirely).
334    for g in to_revoke {
335        if let Some(source_owner) = &source_table.owner {
336            out.revokes_with_owner.push(RevokeWithOwnerObservation {
337                object_label: object_label.clone(),
338                privilege_label: g.privilege.sql_keyword().into(),
339                grantee: g.grantee.clone(),
340                owner: source_owner.clone(),
341            });
342        }
343        if g.columns.is_some() {
344            out.push(
345                Change::RevokeColumnPrivilege {
346                    qname: qname.clone(),
347                    grant: g,
348                },
349                Destructiveness::Safe,
350            );
351        } else {
352            out.push(
353                Change::RevokeObjectPrivilege {
354                    qname: qname.clone(),
355                    kind: OwnerObjectKind::Table,
356                    signature: String::new(),
357                    grant: g,
358                },
359                Destructiveness::Safe,
360            );
361        }
362    }
363    for g in to_add {
364        if g.columns.is_some() {
365            out.push(
366                Change::GrantColumnPrivilege {
367                    qname: qname.clone(),
368                    grant: g,
369                },
370                Destructiveness::Safe,
371            );
372        } else {
373            out.push(
374                Change::GrantObjectPrivilege {
375                    qname: qname.clone(),
376                    kind: OwnerObjectKind::Table,
377                    signature: String::new(),
378                    grant: g,
379                },
380                Destructiveness::Safe,
381            );
382        }
383    }
384    for g in unmanaged {
385        if let GrantTarget::Role(role_name) = &g.grantee {
386            out.unmanaged_grants.push(UnmanagedGrantObservation {
387                object_label: object_label.clone(),
388                privilege_label: g.privilege.sql_keyword().into(),
389                role_name: role_name.clone(),
390            });
391        }
392    }
393}
394
395fn emit_table_attribute_changes(
396    target_table: &Table,
397    source_table: &Table,
398    managed_roles: &BTreeSet<Identifier>,
399    out: &mut ChangeSet,
400) {
401    let qname = &source_table.qname;
402
403    // ---- owner diff ----
404    if let Some(source_owner) = &source_table.owner
405        && target_table.owner.as_ref() != Some(source_owner)
406    {
407        out.push(
408            Change::AlterObjectOwner(AlterObjectOwner {
409                kind: OwnerObjectKind::Table,
410                id: crate::diff::owner_op::OwnedObjectId::Qualified(qname.clone()),
411                signature: String::new(),
412                from: target_table.owner.clone(),
413                to: source_owner.clone(),
414            }),
415            Destructiveness::Safe,
416        );
417    }
418
419    // ---- grant diff ----
420    emit_table_grant_changes(target_table, source_table, managed_roles, out);
421
422    // ---- policy diff (RLS toggles + per-policy changes) ----
423    let mut policy_changes: Vec<Change> = Vec::new();
424    super::policies::diff_policies(target_table, source_table, &mut policy_changes);
425    for c in policy_changes {
426        out.push(c, Destructiveness::Safe);
427    }
428
429    // ---- storage reloptions diff ----
430    let delta = crate::diff::reloptions::table_delta(&target_table.storage, &source_table.storage);
431    if !delta.is_empty() {
432        out.push(
433            Change::SetTableStorage {
434                qname: qname.clone(),
435                options: delta,
436            },
437            Destructiveness::Safe,
438        );
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use std::collections::BTreeSet;
446
447    use crate::identifier::Identifier;
448    use crate::ir::column::Column;
449    use crate::ir::column_type::ColumnType;
450    use crate::ir::grant::{Grant, GrantTarget, Privilege};
451    use crate::ir::partition::{
452        BoundDatum, PartitionBounds, PartitionBy, PartitionColumn, PartitionColumnKind,
453        PartitionOf, PartitionStrategy,
454    };
455    use crate::ir::policy::{Policy, PolicyCommand};
456    use crate::ir::reloptions::TableStorageOptions;
457
458    fn id(s: &str) -> Identifier {
459        Identifier::from_unquoted(s).unwrap()
460    }
461
462    fn qn(name: &str) -> QualifiedName {
463        QualifiedName::new(id("app"), id(name))
464    }
465
466    fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
467        Column {
468            name: id(name),
469            ty,
470            nullable,
471            default: None,
472            identity: None,
473            generated: None,
474            collation: None,
475            storage: None,
476            compression: None,
477            comment: None,
478        }
479    }
480
481    fn users() -> Table {
482        Table {
483            qname: qn("users"),
484            columns: vec![col("id", ColumnType::BigInt, false)],
485            constraints: vec![],
486            partition_by: None,
487            partition_of: None,
488            comment: None,
489            owner: None,
490            grants: vec![],
491            rls_enabled: false,
492            rls_forced: false,
493            policies: vec![],
494            storage: crate::ir::reloptions::TableStorageOptions::default(),
495            access_method: None,
496            tablespace: None,
497        }
498    }
499
500    /// Regression for the soak `[42703] column "X" of relation "Y" does not
501    /// exist` failure. When a column carrying a *multi-column* grant is dropped
502    /// — e.g. live `GRANT SELECT (id, price)` and the source drops `price`,
503    /// keeping `GRANT SELECT (id)` — the grant diff must not emit a
504    /// `RevokeColumnPrivilege` whose column list names the dropped column. PG
505    /// cascade-revokes the column ACL as part of `ALTER TABLE ... DROP COLUMN`,
506    /// so an explicit `REVOKE SELECT (id, price)` fails with 42703 once `price`
507    /// is gone. Dropped columns must be stripped from the target grants before
508    /// diffing; here that leaves `SELECT (id)` on both sides, so no grant change
509    /// is emitted at all (the column drop alone converges the grant).
510    #[test]
511    fn dropped_column_does_not_emit_column_grant_revoke() {
512        let writers = id("writers");
513        let managed_roles: BTreeSet<Identifier> = std::iter::once(writers.clone()).collect();
514
515        let col_grant = |cols: &[&str]| Grant {
516            grantee: GrantTarget::Role(writers.clone()),
517            privilege: Privilege::Select,
518            with_grant_option: false,
519            columns: Some(cols.iter().map(|c| id(c)).collect()),
520        };
521
522        let mut target = Catalog::empty();
523        target.tables.push(Table {
524            columns: vec![
525                col("id", ColumnType::BigInt, false),
526                col("price", ColumnType::BigInt, true),
527            ],
528            grants: vec![col_grant(&["id", "price"])],
529            ..users()
530        });
531
532        let mut source = Catalog::empty();
533        source.tables.push(Table {
534            columns: vec![col("id", ColumnType::BigInt, false)],
535            grants: vec![col_grant(&["id"])],
536            ..users()
537        });
538
539        let mut cs = ChangeSet::new();
540        diff_tables(&target, &source, &mut cs, &managed_roles);
541
542        for entry in &cs.entries {
543            if let Change::RevokeColumnPrivilege { grant, .. } = &entry.change {
544                let cols = grant.columns.as_ref().expect("column grant");
545                assert!(
546                    !cols.iter().any(|c| c.as_str() == "price"),
547                    "must not revoke the dropped column 'price': {grant:?}"
548                );
549            }
550            assert!(
551                !matches!(&entry.change, Change::GrantColumnPrivilege { .. }),
552                "no column grant needed — surviving column 'id' already has it: {:?}",
553                entry.change
554            );
555        }
556    }
557
558    #[test]
559    fn add_table_emits_create_safe() {
560        let target = Catalog::empty();
561        let mut source = Catalog::empty();
562        source.tables.push(users());
563        let mut cs = ChangeSet::new();
564        diff_tables(&target, &source, &mut cs, &BTreeSet::new());
565        assert_eq!(cs.len(), 1);
566        let entry = &cs.entries[0];
567        assert!(matches!(entry.change, Change::CreateTable(_)));
568        assert_eq!(entry.destructiveness, Destructiveness::Safe);
569    }
570
571    #[test]
572    fn new_table_with_attrs_emits_create_plus_attribute_changes() {
573        // A brand-new table (not in target) that carries owner, grants, a
574        // policy, rls_enabled, rls_forced, and non-default storage should emit
575        // one CreateTable *plus* one change per non-default attribute.
576        let target = Catalog::empty();
577        let mut source = Catalog::empty();
578
579        let app_role = id("app");
580        let reader_role = id("reader");
581        let managed_roles: BTreeSet<Identifier> = [app_role.clone(), reader_role.clone()]
582            .into_iter()
583            .collect();
584
585        let table = Table {
586            qname: qn("users"),
587            columns: vec![col("id", ColumnType::BigInt, false)],
588            constraints: vec![],
589            partition_by: None,
590            partition_of: None,
591            comment: None,
592            owner: Some(app_role),
593            grants: vec![Grant {
594                grantee: GrantTarget::Role(reader_role),
595                privilege: Privilege::Select,
596                with_grant_option: false,
597                columns: None,
598            }],
599            rls_enabled: true,
600            rls_forced: true,
601            policies: vec![Policy {
602                name: id("tenant_isolation"),
603                permissive: true,
604                command: PolicyCommand::All,
605                roles: vec![GrantTarget::Public],
606                using: None,
607                with_check: None,
608            }],
609            storage: TableStorageOptions {
610                fillfactor: Some(70),
611                ..Default::default()
612            },
613            access_method: None,
614            tablespace: None,
615        };
616        source.tables.push(table);
617
618        let mut cs = ChangeSet::new();
619        diff_tables(&target, &source, &mut cs, &managed_roles);
620
621        let changes: Vec<&Change> = cs.entries.iter().map(|e| &e.change).collect();
622
623        // Must have a CreateTable.
624        assert!(
625            changes.iter().any(|c| matches!(c, Change::CreateTable(_))),
626            "missing CreateTable; got: {changes:?}"
627        );
628        // Must have an AlterObjectOwner.
629        assert!(
630            changes
631                .iter()
632                .any(|c| matches!(c, Change::AlterObjectOwner(_))),
633            "missing AlterObjectOwner; got: {changes:?}"
634        );
635        // Must have a GrantObjectPrivilege.
636        assert!(
637            changes
638                .iter()
639                .any(|c| matches!(c, Change::GrantObjectPrivilege { .. })),
640            "missing GrantObjectPrivilege; got: {changes:?}"
641        );
642        // Must have a CreatePolicy.
643        assert!(
644            changes
645                .iter()
646                .any(|c| matches!(c, Change::CreatePolicy { .. })),
647            "missing CreatePolicy; got: {changes:?}"
648        );
649        // Must have SetTableRowSecurity (rls_enabled = true).
650        assert!(
651            changes
652                .iter()
653                .any(|c| matches!(c, Change::SetTableRowSecurity { enable: true, .. })),
654            "missing SetTableRowSecurity; got: {changes:?}"
655        );
656        // Must have SetTableForceRowSecurity (rls_forced = true).
657        assert!(
658            changes
659                .iter()
660                .any(|c| matches!(c, Change::SetTableForceRowSecurity { force: true, .. })),
661            "missing SetTableForceRowSecurity; got: {changes:?}"
662        );
663        // Must have SetTableStorage.
664        assert!(
665            changes
666                .iter()
667                .any(|c| matches!(c, Change::SetTableStorage { .. })),
668            "missing SetTableStorage; got: {changes:?}"
669        );
670    }
671
672    #[test]
673    fn drop_table_emits_data_loss_warning() {
674        let mut target = Catalog::empty();
675        target.tables.push(users());
676        let source = Catalog::empty();
677        let mut cs = ChangeSet::new();
678        diff_tables(&target, &source, &mut cs, &BTreeSet::new());
679        assert_eq!(cs.len(), 1);
680        let entry = &cs.entries[0];
681        match &entry.change {
682            Change::DropTable {
683                qname,
684                row_count_estimate,
685            } => {
686                assert_eq!(qname, &qn("users"));
687                assert!(row_count_estimate.is_none());
688            }
689            other => panic!("expected DropTable, got {other:?}"),
690        }
691        assert!(entry.destructiveness.data_loss_risk());
692        assert!(
693            entry
694                .destructiveness
695                .reason()
696                .unwrap()
697                .contains("app.users")
698        );
699    }
700
701    #[test]
702    fn equal_tables_emit_nothing() {
703        let mut target = Catalog::empty();
704        target.tables.push(users());
705        let mut source = Catalog::empty();
706        source.tables.push(users());
707        let mut cs = ChangeSet::new();
708        diff_tables(&target, &source, &mut cs, &BTreeSet::new());
709        assert!(cs.is_empty());
710    }
711
712    #[test]
713    fn comment_only_change_emits_alter_with_set_comment() {
714        let mut target = Catalog::empty();
715        target.tables.push(users());
716        let mut source = Catalog::empty();
717        source.tables.push(Table {
718            comment: Some("the users table".into()),
719            ..users()
720        });
721        let mut cs = ChangeSet::new();
722        diff_tables(&target, &source, &mut cs, &BTreeSet::new());
723        assert_eq!(cs.len(), 1);
724        let entry = &cs.entries[0];
725        match &entry.change {
726            Change::AlterTable { qname, ops } => {
727                assert_eq!(qname, &qn("users"));
728                assert_eq!(ops.len(), 1);
729                assert!(matches!(
730                    ops[0].op,
731                    super::super::table_op::TableOp::SetTableComment { .. }
732                ));
733            }
734            other => panic!("expected AlterTable, got {other:?}"),
735        }
736        assert_eq!(entry.destructiveness, Destructiveness::Safe);
737    }
738
739    // ---- partition test helpers ----
740
741    fn qn2(schema: &str, name: &str) -> QualifiedName {
742        QualifiedName::new(id(schema), id(name))
743    }
744
745    /// A plain (non-partitioned) table with the given schema/name.
746    fn sample_table_with_qname(schema: &str, name: &str) -> Table {
747        Table {
748            qname: qn2(schema, name),
749            columns: vec![col("id", ColumnType::BigInt, false)],
750            constraints: vec![],
751            partition_by: None,
752            partition_of: None,
753            comment: None,
754            owner: None,
755            grants: vec![],
756            rls_enabled: false,
757            rls_forced: false,
758            policies: vec![],
759            storage: crate::ir::reloptions::TableStorageOptions::default(),
760            access_method: None,
761            tablespace: None,
762        }
763    }
764
765    /// Construct a `PartitionOf` with `DEFAULT` bounds.
766    fn po_default(schema: &str, parent: &str) -> PartitionOf {
767        PartitionOf {
768            parent: qn2(schema, parent),
769            bounds: PartitionBounds::Default,
770        }
771    }
772
773    /// Construct a `PartitionOf` with a single-column RANGE FROM literal TO MAXVALUE.
774    fn po_range(schema: &str, parent: &str, from_lit: &str) -> PartitionOf {
775        use crate::ir::default_expr::NormalizedExpr;
776        PartitionOf {
777            parent: qn2(schema, parent),
778            bounds: PartitionBounds::Range {
779                from: vec![BoundDatum::Literal(NormalizedExpr::from_text(from_lit))],
780                to: vec![BoundDatum::MaxValue],
781            },
782        }
783    }
784
785    /// Construct a `PartitionBy LIST` on a single column.
786    fn pb_list(col_name: &str) -> PartitionBy {
787        PartitionBy {
788            strategy: PartitionStrategy::List,
789            columns: vec![PartitionColumn {
790                kind: PartitionColumnKind::Column(id(col_name)),
791                collation: None,
792                opclass: None,
793            }],
794        }
795    }
796
797    /// Construct a `PartitionBy RANGE` on a single column.
798    fn pb_range(col_name: &str) -> PartitionBy {
799        PartitionBy {
800            strategy: PartitionStrategy::Range,
801            columns: vec![PartitionColumn {
802                kind: PartitionColumnKind::Column(id(col_name)),
803                collation: None,
804                opclass: None,
805            }],
806        }
807    }
808
809    /// Diff `source_table` against `target_table` (as the only table in each
810    /// catalog) and return the collected changes.
811    fn run_diff(source: &Table, target: &Table) -> Vec<Change> {
812        let mut src_catalog = Catalog::empty();
813        src_catalog.tables.push(source.clone());
814        let mut tgt_catalog = Catalog::empty();
815        tgt_catalog.tables.push(target.clone());
816        let mut cs = ChangeSet::new();
817        diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
818        cs.entries.into_iter().map(|e| e.change).collect()
819    }
820
821    /// Like `run_diff` but returns `Err` if any `Change::UnsupportedDiff` is
822    /// emitted, or `Ok(changes)` otherwise.
823    fn try_diff(source: &Table, target: &Table) -> Result<Vec<Change>, String> {
824        let changes = run_diff(source, target);
825        for c in &changes {
826            if let Change::UnsupportedDiff { reason } = c {
827                return Err(reason.clone());
828            }
829        }
830        Ok(changes)
831    }
832
833    // ---- partition tests ----
834
835    #[test]
836    fn detects_attach_partition_when_source_declares_it() {
837        // source says partition; catalog says standalone → AttachPartition
838        let mut src = sample_table_with_qname("app", "orders_2024");
839        src.partition_of = Some(po_default("app", "orders"));
840        let target = sample_table_with_qname("app", "orders_2024");
841        let changes = run_diff(&src, &target);
842        assert!(
843            changes
844                .iter()
845                .any(|c| matches!(c, Change::Table(TableChange::AttachPartition { .. }))),
846            "got: {changes:?}"
847        );
848    }
849
850    #[test]
851    fn detects_detach_partition_when_source_drops_declaration() {
852        let src = sample_table_with_qname("app", "orders_2024");
853        let mut target = sample_table_with_qname("app", "orders_2024");
854        target.partition_of = Some(po_default("app", "orders"));
855        let changes = run_diff(&src, &target);
856        assert!(
857            changes
858                .iter()
859                .any(|c| matches!(c, Change::Table(TableChange::DetachPartition { .. }))),
860            "got: {changes:?}"
861        );
862    }
863
864    #[test]
865    fn bounds_change_emits_detach_then_attach() {
866        let mut src = sample_table_with_qname("app", "orders_2024");
867        src.partition_of = Some(po_range("app", "orders", "10"));
868        let mut target = sample_table_with_qname("app", "orders_2024");
869        target.partition_of = Some(po_range("app", "orders", "20"));
870        let changes = run_diff(&src, &target);
871        let positions: Vec<_> = changes
872            .iter()
873            .filter_map(|c| match c {
874                Change::Table(TableChange::DetachPartition { .. }) => Some("detach"),
875                Change::Table(TableChange::AttachPartition { .. }) => Some("attach"),
876                _ => None,
877            })
878            .collect();
879        assert_eq!(positions, vec!["detach", "attach"]);
880    }
881
882    #[test]
883    fn parent_partition_by_change_errors() {
884        let mut src = sample_table_with_qname("app", "orders");
885        src.partition_by = Some(pb_list("region"));
886        let mut target = sample_table_with_qname("app", "orders");
887        target.partition_by = Some(pb_range("placed"));
888        let err = try_diff(&src, &target).unwrap_err();
889        assert!(err.contains("PARTITION BY"), "got: {err}");
890    }
891
892    /// Changing the access method on an existing table emits NO diff changes.
893    ///
894    /// `ALTER TABLE … SET ACCESS METHOD` cannot be done online safely; a lint
895    /// (Task 6) surfaces the advisory warning instead. The differ intentionally
896    /// does not diff this field for existing tables — only `create_table`
897    /// rendering carries it for new tables.
898    #[test]
899    fn access_method_change_on_existing_table_emits_nothing() {
900        // Use two distinct non-heap AMs so canon doesn't strip either to None.
901        let mut src = sample_table_with_qname("app", "events");
902        src.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
903        let mut target = sample_table_with_qname("app", "events");
904        target.access_method = Some(Identifier::from_unquoted("zheap").unwrap());
905
906        let changes = run_diff(&src, &target);
907        assert!(
908            changes.is_empty(),
909            "access_method change on an existing table must emit no Changes (lint handles it); got: {changes:?}"
910        );
911    }
912
913    // ---- tablespace diff tests ----
914
915    /// Helper: run a tablespace change diff between two `Table`s, returning the
916    /// single `TableOpEntry` for `SetTableSpace`. Panics with a descriptive
917    /// message if the shape is wrong.
918    fn extract_set_tablespace_op(source: &Table, target: &Table) -> TableOpEntry {
919        let mut src_catalog = Catalog::empty();
920        src_catalog.tables.push(source.clone());
921        let mut tgt_catalog = Catalog::empty();
922        tgt_catalog.tables.push(target.clone());
923        let mut cs = ChangeSet::new();
924        diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
925        assert_eq!(cs.len(), 1, "expected 1 Change entry, got {}", cs.len());
926        let entry = &cs.entries[0];
927        match &entry.change {
928            Change::AlterTable { ops, .. } => {
929                let ts_ops: Vec<&TableOpEntry> = ops
930                    .iter()
931                    .filter(|o| {
932                        matches!(o.op, super::super::table_op::TableOp::SetTableSpace { .. })
933                    })
934                    .collect();
935                assert_eq!(
936                    ts_ops.len(),
937                    1,
938                    "expected exactly 1 SetTableSpace op, got {}: {ops:?}",
939                    ts_ops.len()
940                );
941                (*ts_ops[0]).clone()
942            }
943            other => panic!("expected AlterTable, got {other:?}"),
944        }
945    }
946
947    /// A plain leaf table (no `partition_by`, no `partition_of`) changing tablespace
948    /// must emit `SetTableSpace` with `RequiresApproval`.
949    #[test]
950    fn leaf_table_tablespace_change_requires_approval() {
951        let mut src = sample_table_with_qname("app", "orders");
952        src.tablespace = Some(id("fast_ssd"));
953        let target = sample_table_with_qname("app", "orders");
954        // target.tablespace is None (default)
955
956        let op_entry = extract_set_tablespace_op(&src, &target);
957        assert!(
958            matches!(
959                op_entry.op,
960                super::super::table_op::TableOp::SetTableSpace { .. }
961            ),
962            "expected SetTableSpace op"
963        );
964        assert!(
965            matches!(
966                op_entry.destructiveness,
967                Destructiveness::RequiresApproval { .. }
968            ),
969            "leaf table tablespace change must be RequiresApproval; got {:?}",
970            op_entry.destructiveness
971        );
972    }
973
974    /// A partition child (`partition_of` set, `partition_by` None) changing
975    /// tablespace must emit `SetTableSpace` with `RequiresApproval`.
976    #[test]
977    fn partition_child_tablespace_change_requires_approval() {
978        let mut src = sample_table_with_qname("app", "orders_2024");
979        src.partition_of = Some(po_default("app", "orders"));
980        src.tablespace = Some(id("fast_ssd"));
981        let mut target = sample_table_with_qname("app", "orders_2024");
982        target.partition_of = Some(po_default("app", "orders"));
983        // target.tablespace is None (default)
984
985        let op_entry = extract_set_tablespace_op(&src, &target);
986        assert!(
987            matches!(
988                op_entry.op,
989                super::super::table_op::TableOp::SetTableSpace { .. }
990            ),
991            "expected SetTableSpace op"
992        );
993        assert!(
994            matches!(
995                op_entry.destructiveness,
996                Destructiveness::RequiresApproval { .. }
997            ),
998            "partition child tablespace change must be RequiresApproval; got {:?}",
999            op_entry.destructiveness
1000        );
1001    }
1002
1003    /// A partitioned parent (`partition_by` set) changing tablespace must emit
1004    /// `SetTableSpace` with `Destructiveness::Safe` (only metadata + `pg_default` moves).
1005    #[test]
1006    fn partitioned_parent_tablespace_change_is_safe() {
1007        let mut src = sample_table_with_qname("app", "orders");
1008        src.partition_by = Some(pb_list("region"));
1009        src.tablespace = Some(id("fast_ssd"));
1010        let mut target = sample_table_with_qname("app", "orders");
1011        target.partition_by = Some(pb_list("region"));
1012        // target.tablespace is None
1013
1014        let op_entry = extract_set_tablespace_op(&src, &target);
1015        assert!(
1016            matches!(
1017                op_entry.op,
1018                super::super::table_op::TableOp::SetTableSpace { .. }
1019            ),
1020            "expected SetTableSpace op"
1021        );
1022        assert_eq!(
1023            op_entry.destructiveness,
1024            Destructiveness::Safe,
1025            "partitioned parent tablespace change must be Safe; got {:?}",
1026            op_entry.destructiveness
1027        );
1028    }
1029
1030    /// Equal tablespaces (both `None`) emit no `SetTableSpace` op.
1031    #[test]
1032    fn equal_tablespace_none_emits_no_op() {
1033        let src = sample_table_with_qname("app", "orders");
1034        let target = sample_table_with_qname("app", "orders");
1035        // Both have tablespace: None
1036        let changes = run_diff(&src, &target);
1037        assert!(
1038            changes.is_empty(),
1039            "equal tablespace (both None) must emit no Changes; got: {changes:?}"
1040        );
1041    }
1042
1043    /// Equal tablespaces (both `Some("x")`) emit no `SetTableSpace` op.
1044    #[test]
1045    fn equal_tablespace_some_emits_no_op() {
1046        let mut src = sample_table_with_qname("app", "orders");
1047        src.tablespace = Some(id("fast_ssd"));
1048        let mut target = sample_table_with_qname("app", "orders");
1049        target.tablespace = Some(id("fast_ssd"));
1050
1051        let changes = run_diff(&src, &target);
1052        assert!(
1053            changes.is_empty(),
1054            "equal tablespace (both Some) must emit no Changes; got: {changes:?}"
1055        );
1056    }
1057}