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