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