Skip to main content

pgevolve_core/diff/
change.rs

1//! `Change` — one entry in a [`ChangeSet`](super::changeset::ChangeSet).
2//!
3//! A `Change` describes a structural difference between a target catalog and
4//! a source catalog at the level of a top-level object (schema, table, index,
5//! sequence). Per-column / per-constraint operations live inside the
6//! [`AlterTable`](Change::AlterTable) variant as a list of
7//! [`TableOpEntry`]; per-field sequence updates
8//! live inside [`AlterSequence`](Change::AlterSequence) as
9//! [`SequenceOpEntry`].
10
11use serde::{Deserialize, Serialize};
12
13use crate::identifier::{Identifier, QualifiedName};
14use crate::ir::column_type::ColumnType;
15use crate::ir::default_expr::NormalizedExpr;
16use crate::ir::default_privileges::DefaultPrivObjectType;
17use crate::ir::extension::Extension;
18use crate::ir::function::{Function, NormalizedArgTypes};
19use crate::ir::grant::{Grant, GrantTarget};
20use crate::ir::index::Index;
21use crate::ir::partition::PartitionBounds;
22use crate::ir::procedure::Procedure;
23use crate::ir::schema::Schema;
24use crate::ir::sequence::Sequence;
25use crate::ir::table::Table;
26use crate::ir::trigger::Trigger;
27use crate::ir::user_type::{CompositeAttribute, DomainCheck, UserType};
28use crate::ir::view::{MaterializedView, View};
29
30use super::destructiveness::Destructiveness;
31use super::owner_op::{AlterObjectOwner, OwnerObjectKind};
32use super::sequence_op::SequenceOpEntry;
33use super::table_op::TableOpEntry;
34
35/// A change paired with its destructiveness classification.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct ChangeEntry {
38    /// The structural change.
39    pub change: Change,
40    /// Risk classification for this change.
41    pub destructiveness: Destructiveness,
42}
43
44/// One structural change between two catalogs.
45///
46/// `Change` is intentionally not boxed: the enum's footprint is dominated by
47/// `CreateTable` / `CreateIndex` / `ReplaceIndex` payloads, but `ChangeSet`
48/// only stores at most one of each per object, so `Vec` overhead is the
49/// dominant cost and boxing would just add an extra allocation per entry.
50#[allow(clippy::large_enum_variant)]
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
53pub enum Change {
54    /// Add a schema.
55    CreateSchema(Schema),
56    /// Drop a schema by name.
57    DropSchema(Identifier),
58    /// Update schema metadata (only `comment` in v0.1).
59    AlterSchema {
60        /// Schema name.
61        name: Identifier,
62        /// New comment value (`None` clears the comment).
63        comment: Option<String>,
64    },
65
66    /// Add a table.
67    CreateTable(Table),
68    /// Drop a table.
69    DropTable {
70        /// Table qname.
71        qname: QualifiedName,
72        /// Best-effort estimate of row count, populated by the planner from
73        /// `pg_class.reltuples`. The differ leaves this `None`.
74        row_count_estimate: Option<i64>,
75    },
76    /// Alter a table — column / constraint / comment operations.
77    AlterTable {
78        /// Target table qname.
79        qname: QualifiedName,
80        /// Per-table operations. Order is the planner's job; the differ emits
81        /// these in arbitrary order.
82        ops: Vec<TableOpEntry>,
83    },
84
85    /// Create an index.
86    CreateIndex(Index),
87    /// Drop an index.
88    DropIndex(QualifiedName),
89    /// Replace an index (DROP + CREATE) when a property change requires it.
90    ReplaceIndex {
91        /// The index as it exists in the target.
92        from: Index,
93        /// The index as it should exist in the source.
94        to: Index,
95    },
96
97    /// Create a sequence.
98    CreateSequence(Sequence),
99    /// Drop a sequence.
100    DropSequence(QualifiedName),
101    /// Alter a sequence — per-field operations.
102    AlterSequence {
103        /// Target sequence qname.
104        qname: QualifiedName,
105        /// Per-sequence operations.
106        ops: Vec<SequenceOpEntry>,
107    },
108
109    /// A constraint exists in the catalog but is `NOT VALID`
110    /// (`pg_constraint.convalidated = false`). Planner emits
111    /// `VALIDATE CONSTRAINT`. Non-destructive.
112    ValidateConstraint {
113        /// Qualified name of the table owning the constraint.
114        table: QualifiedName,
115        /// Constraint name.
116        constraint: Identifier,
117    },
118    /// An index exists in the catalog but is `INVALID`
119    /// (`pg_index.indisvalid = false`). Planner emits `DROP INDEX + CREATE
120    /// INDEX` to rebuild it. Non-destructive.
121    RecreateIndex {
122        /// Qualified name of the invalid index.
123        qname: QualifiedName,
124    },
125
126    /// A view-level change.
127    View(ViewChange),
128    /// `CREATE OR REPLACE VIEW … WITH [LOCAL|CASCADED] CHECK OPTION` or the
129    /// inverse (set/unset check option on an existing view). PG has no direct
130    /// `ALTER VIEW … SET CHECK OPTION`; pgevolve emits a full
131    /// `CREATE OR REPLACE VIEW` carrying the new option.
132    AlterViewSetCheckOption {
133        /// Schema-qualified view name.
134        qname: QualifiedName,
135        /// The desired check option state in the source.
136        /// `None` = source declares no check option (clear it).
137        new_value: Option<crate::ir::view::CheckOption>,
138    },
139    /// A materialized-view-level change.
140    Mv(MvChange),
141    /// A user-defined type change (enum, domain, composite).
142    UserType(UserTypeChange),
143    /// A user-defined function change.
144    Function(FunctionChange),
145    /// A user-defined procedure change.
146    Procedure(ProcedureChange),
147    /// An extension change.
148    Extension(ExtensionChange),
149    /// A trigger change.
150    Trigger(TriggerChange),
151    /// A partition-membership change (ATTACH / DETACH PARTITION).
152    Table(TableChange),
153    /// Grant an object-level privilege on a grantable object (non-column).
154    ///
155    /// Emitted when a grant appears in source but not in the target catalog.
156    GrantObjectPrivilege {
157        /// Qualified name of the grantable object (schema, table, sequence,
158        /// view, function, procedure, or type).
159        qname: QualifiedName,
160        /// Which kind of object this is (drives the SQL keyword in the renderer).
161        kind: OwnerObjectKind,
162        /// Argument signature for routines (e.g., `"(int, text)"`).
163        /// Empty string for non-routine object kinds.
164        #[serde(default)]
165        signature: String,
166        /// The full grant to apply.
167        grant: Grant,
168    },
169    /// Revoke an object-level privilege from a grantable object (non-column).
170    ///
171    /// Only emitted for managed grantees (see [`super::grants::diff_grants`]).
172    RevokeObjectPrivilege {
173        /// Qualified name of the grantable object.
174        qname: QualifiedName,
175        /// Which kind of object this is.
176        kind: OwnerObjectKind,
177        /// Argument signature for routines (e.g., `"(int, text)"`).
178        /// Empty string for non-routine object kinds.
179        #[serde(default)]
180        signature: String,
181        /// The full grant to revoke.
182        grant: Grant,
183    },
184    /// Grant a column-level privilege on a table, view, or materialized view.
185    ///
186    /// Emitted when the grant's `columns` field is `Some(_)`.
187    GrantColumnPrivilege {
188        /// Qualified name of the table / view / materialized view.
189        qname: QualifiedName,
190        /// The full grant (including the `columns` list).
191        grant: Grant,
192    },
193    /// Revoke a column-level privilege from a table, view, or materialized view.
194    ///
195    /// Only emitted for managed grantees.
196    RevokeColumnPrivilege {
197        /// Qualified name of the table / view / materialized view.
198        qname: QualifiedName,
199        /// The full grant (including the `columns` list).
200        grant: Grant,
201    },
202    /// Change the owner of a grantable object.
203    ///
204    /// Emitted when the source declares an owner (`owner: Some(_)`) and the
205    /// target owner differs. When the source has `owner: None`, ownership is
206    /// unmanaged and no change is emitted.
207    AlterObjectOwner(AlterObjectOwner),
208    /// Add or remove a default privilege for a `(FOR ROLE, IN SCHEMA?,
209    /// object-type)` key.
210    AlterDefaultPrivileges {
211        /// `FOR ROLE x` — the grantor role.
212        target_role: Identifier,
213        /// `IN SCHEMA y` — scope. `None` = global.
214        schema: Option<Identifier>,
215        /// Object-type discriminant.
216        object_type: DefaultPrivObjectType,
217        /// `true` = GRANT step, `false` = REVOKE step.
218        is_grant: bool,
219        /// The grantee and privilege being adjusted.
220        grant: Grant,
221    },
222
223    /// Create a new policy on the named table.
224    CreatePolicy {
225        /// Table the policy belongs to.
226        table: QualifiedName,
227        /// The policy to create.
228        policy: crate::ir::policy::Policy,
229    },
230    /// Drop a policy from the named table.
231    DropPolicy {
232        /// Table the policy belongs to.
233        table: QualifiedName,
234        /// Name of the policy to drop.
235        name: Identifier,
236    },
237    /// Alter a policy's roles / USING / WITH CHECK.
238    ///
239    /// Note: PG rejects `ALTER POLICY` when the command kind changes — the
240    /// differ emits `DropPolicy` + `CreatePolicy` in that case instead.
241    AlterPolicy {
242        /// Table the policy belongs to.
243        table: QualifiedName,
244        /// The desired policy state.
245        policy: crate::ir::policy::Policy,
246    },
247    /// Toggle a table's `ROW LEVEL SECURITY`.
248    SetTableRowSecurity {
249        /// Qualified name of the table.
250        qname: QualifiedName,
251        /// `true` = `ENABLE ROW LEVEL SECURITY`, `false` = `DISABLE`.
252        enable: bool,
253    },
254    /// Toggle a table's `FORCE ROW LEVEL SECURITY`.
255    SetTableForceRowSecurity {
256        /// Qualified name of the table.
257        qname: QualifiedName,
258        /// `true` = `FORCE ROW LEVEL SECURITY`, `false` = `NO FORCE`.
259        force: bool,
260    },
261
262    /// Set table storage reloptions. `options` carries the sparse delta — only
263    /// the fields whose source value differs from the catalog.
264    ///
265    /// No `Reset*` variant: the lenient policy treats source `None` as "skip".
266    SetTableStorage {
267        /// Qualified name of the table.
268        qname: QualifiedName,
269        /// Sparse delta of options to apply.
270        options: crate::ir::reloptions::TableStorageOptions,
271    },
272    /// Set index storage reloptions. Sparse delta.
273    ///
274    /// No `Reset*` variant: the lenient policy treats source `None` as "skip".
275    SetIndexStorage {
276        /// Qualified name of the index.
277        qname: QualifiedName,
278        /// Sparse delta of options to apply.
279        options: crate::ir::reloptions::IndexStorageOptions,
280    },
281    /// Set materialized view storage reloptions. Sparse delta.
282    ///
283    /// No `Reset*` variant: the lenient policy treats source `None` as "skip".
284    SetMaterializedViewStorage {
285        /// Qualified name of the materialized view.
286        qname: QualifiedName,
287        /// Sparse delta of options to apply.
288        options: crate::ir::reloptions::TableStorageOptions,
289    },
290
291    /// `CREATE PUBLICATION ...`
292    CreatePublication(crate::ir::publication::Publication),
293    /// `DROP PUBLICATION ...` — destructive.
294    DropPublication {
295        /// Publication name.
296        name: crate::identifier::Identifier,
297    },
298    /// `DROP PUBLICATION old; CREATE PUBLICATION new;` — destructive; used
299    /// when the publication's scope mode switches (`AllTables` ↔ `Selective`).
300    ReplacePublication {
301        /// The publication as it exists in the target.
302        from: crate::ir::publication::Publication,
303        /// The publication as it should exist in the source.
304        to: crate::ir::publication::Publication,
305    },
306    /// `ALTER PUBLICATION p ADD TABLE x [(cols)] [WHERE (filter)]`
307    AlterPublicationAddTable {
308        /// Publication name.
309        publication: crate::identifier::Identifier,
310        /// The table entry to add.
311        table: crate::ir::publication::PublishedTable,
312    },
313    /// `ALTER PUBLICATION p DROP TABLE x`
314    AlterPublicationDropTable {
315        /// Publication name.
316        publication: crate::identifier::Identifier,
317        /// Qualified name of the table to drop.
318        qname: crate::identifier::QualifiedName,
319    },
320    /// `ALTER PUBLICATION p SET TABLE x (cols) WHERE (filter)`
321    AlterPublicationSetTable {
322        /// Publication name.
323        publication: crate::identifier::Identifier,
324        /// The desired table entry state.
325        table: crate::ir::publication::PublishedTable,
326    },
327    /// `ALTER PUBLICATION p ADD TABLES IN SCHEMA s` (PG15+)
328    AlterPublicationAddSchema {
329        /// Publication name.
330        publication: crate::identifier::Identifier,
331        /// Schema to add.
332        schema: crate::identifier::Identifier,
333    },
334    /// `ALTER PUBLICATION p DROP TABLES IN SCHEMA s` (PG15+)
335    AlterPublicationDropSchema {
336        /// Publication name.
337        publication: crate::identifier::Identifier,
338        /// Schema to drop.
339        schema: crate::identifier::Identifier,
340    },
341    /// `ALTER PUBLICATION p SET (publish = '...')`
342    AlterPublicationSetPublish {
343        /// Publication name.
344        publication: crate::identifier::Identifier,
345        /// Desired publish-kinds bitset.
346        kinds: crate::ir::publication::PublishKinds,
347    },
348    /// `ALTER PUBLICATION p SET (publish_via_partition_root = ...)`
349    AlterPublicationSetViaRoot {
350        /// Publication name.
351        publication: crate::identifier::Identifier,
352        /// Desired value.
353        value: bool,
354    },
355    /// `COMMENT ON PUBLICATION p IS '...'`
356    CommentOnPublication {
357        /// Publication name.
358        name: crate::identifier::Identifier,
359        /// New comment value (`None` clears the comment).
360        comment: Option<String>,
361    },
362
363    /// `CREATE STATISTICS ...`
364    CreateStatistic(crate::ir::statistic::Statistic),
365    /// `DROP STATISTICS ...` — destructive.
366    DropStatistic {
367        /// Schema-qualified statistic name.
368        qname: crate::identifier::QualifiedName,
369    },
370    /// `DROP STATISTICS old; CREATE STATISTICS new;` — destructive; used
371    /// when columns / kinds / target table differ (PG has no in-place ALTER
372    /// for those fields).
373    ReplaceStatistic {
374        /// The statistic as it exists in the target.
375        from: crate::ir::statistic::Statistic,
376        /// The statistic as it should exist in the source.
377        to: crate::ir::statistic::Statistic,
378    },
379    /// `ALTER STATISTICS s SET STATISTICS n` — analyze target.
380    AlterStatisticSetTarget {
381        /// Schema-qualified statistic name.
382        qname: crate::identifier::QualifiedName,
383        /// New statistics target value.
384        value: i32,
385    },
386    /// `COMMENT ON STATISTICS s IS '...'`
387    CommentOnStatistic {
388        /// Schema-qualified statistic name.
389        qname: crate::identifier::QualifiedName,
390        /// New comment value (`None` clears the comment).
391        comment: Option<String>,
392    },
393
394    /// `CREATE SUBSCRIPTION ...`
395    CreateSubscription(crate::ir::subscription::Subscription),
396    /// `DROP SUBSCRIPTION ...` — destructive.
397    DropSubscription {
398        /// Subscription name.
399        name: crate::identifier::Identifier,
400    },
401    /// `ALTER SUBSCRIPTION s CONNECTION '...'`
402    AlterSubscriptionConnection {
403        /// Subscription name.
404        name: crate::identifier::Identifier,
405        /// New connection string (may contain `${VAR}` placeholders).
406        new_connection: String,
407    },
408    /// `ALTER SUBSCRIPTION s ADD PUBLICATION p`
409    AlterSubscriptionAddPublication {
410        /// Subscription name.
411        name: crate::identifier::Identifier,
412        /// Publication to add.
413        publication: crate::identifier::Identifier,
414    },
415    /// `ALTER SUBSCRIPTION s DROP PUBLICATION p`
416    AlterSubscriptionDropPublication {
417        /// Subscription name.
418        name: crate::identifier::Identifier,
419        /// Publication to drop.
420        publication: crate::identifier::Identifier,
421    },
422    /// Reserved: pgevolve never emits this (granular ADD/DROP only), but
423    /// the parser accepts source `ALTER SUBSCRIPTION s SET PUBLICATION …` for
424    /// normalizing into the IR's publications field. The variant exists so
425    /// `kind_name` / `parse_kind_name` round-trip every legal `StepKind` name.
426    AlterSubscriptionSetPublication {
427        /// Subscription name.
428        name: crate::identifier::Identifier,
429        /// Publication list.
430        publications: Vec<crate::identifier::Identifier>,
431    },
432    /// `ALTER SUBSCRIPTION s SET (option = value, ...)` — sparse-delta.
433    ///
434    /// `create_slot` and `copy_data` are NEVER included (CREATE-only PG options).
435    AlterSubscriptionSetOptions {
436        /// Subscription name.
437        name: crate::identifier::Identifier,
438        /// Sparse options delta — only changed fields are `Some`.
439        options: crate::ir::subscription::SubscriptionOptions,
440    },
441    /// `COMMENT ON SUBSCRIPTION s IS '...'`
442    CommentOnSubscription {
443        /// Subscription name.
444        name: crate::identifier::Identifier,
445        /// New comment value (`None` clears the comment).
446        comment: Option<String>,
447    },
448
449    /// A change that cannot be performed in-place.
450    ///
451    /// Emitted by the differ when it detects a structural difference that has
452    /// no safe automatic migration path (e.g., changing a table's `PARTITION BY`
453    /// clause). The ordering phase converts this into a [`PlanError`] so the
454    /// plan never reaches execution.
455    ///
456    /// [`PlanError`]: crate::plan::error::PlanError
457    UnsupportedDiff {
458        /// Human-readable explanation of what changed and why it cannot be
459        /// performed in-place.
460        reason: String,
461    },
462}
463
464// Silence the unused-import lint — OwnerObjectKind and GrantTarget are used in
465// the variants above but Rust's lint fires on the `use` line when all uses are
466// inside the enum definition.
467const _: () = {
468    let _ = core::mem::size_of::<OwnerObjectKind>();
469    let _ = core::mem::size_of::<GrantTarget>();
470};
471
472/// A structural change to a single user-defined type.
473#[allow(clippy::large_enum_variant)]
474#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
476pub enum UserTypeChange {
477    /// Create a new user-defined type.
478    Create(UserType),
479    /// Drop a user-defined type by qualified name.
480    Drop(QualifiedName),
481
482    /// Add a new label to an existing enum type.
483    EnumAddValue {
484        /// Qualified name of the enum type.
485        qname: QualifiedName,
486        /// The new label string.
487        value: String,
488        /// If `Some`, the new value is placed immediately before this label.
489        before: Option<String>,
490        /// If `Some`, the new value is placed immediately after this label.
491        after: Option<String>,
492    },
493    /// Rename an existing enum label.
494    EnumRenameValue {
495        /// Qualified name of the enum type.
496        qname: QualifiedName,
497        /// The existing label to rename.
498        from: String,
499        /// The new label name.
500        to: String,
501    },
502
503    /// Add a CHECK constraint to a domain.
504    DomainAddCheck {
505        /// Qualified name of the domain type.
506        qname: QualifiedName,
507        /// The constraint to add.
508        constraint: DomainCheck,
509    },
510    /// Drop a named CHECK constraint from a domain.
511    DomainDropCheck {
512        /// Qualified name of the domain type.
513        qname: QualifiedName,
514        /// The constraint name to drop.
515        name: Identifier,
516    },
517    /// Set (or clear) the DEFAULT expression on a domain.
518    DomainSetDefault {
519        /// Qualified name of the domain type.
520        qname: QualifiedName,
521        /// New default expression (`None` clears the default).
522        default: Option<NormalizedExpr>,
523    },
524    /// Toggle the `NOT NULL` constraint on a domain.
525    DomainSetNotNull {
526        /// Qualified name of the domain type.
527        qname: QualifiedName,
528        /// `true` means NOT NULL (i.e., `nullable = false`).
529        not_null: bool,
530    },
531
532    /// Add a new attribute to a composite type.
533    CompositeAddAttribute {
534        /// Qualified name of the composite type.
535        qname: QualifiedName,
536        /// The attribute to add.
537        attribute: CompositeAttribute,
538    },
539    /// Drop an attribute from a composite type.
540    CompositeDropAttribute {
541        /// Qualified name of the composite type.
542        qname: QualifiedName,
543        /// The attribute name to drop.
544        name: Identifier,
545    },
546    /// Change the type of an existing composite attribute.
547    CompositeAlterAttributeType {
548        /// Qualified name of the composite type.
549        qname: QualifiedName,
550        /// The attribute name whose type is being changed.
551        attribute: Identifier,
552        /// The new column type.
553        new_type: ColumnType,
554    },
555
556    /// Set (or clear) the `COMMENT ON TYPE` for this type.
557    SetComment {
558        /// Qualified name of the type.
559        qname: QualifiedName,
560        /// New comment (`None` clears the comment).
561        comment: Option<String>,
562    },
563
564    /// Emitted when the requested change cannot be done in place via `ALTER`.
565    ///
566    /// T8's cascade walker appends `ReplaceBody` for any views/MVs that depend
567    /// on the type so they are recreated after the type is rebuilt. T9's SQL
568    /// emitter expands this single entry into `DROP TYPE … CASCADE` plus
569    /// `CREATE TYPE`, then the recreated dependents follow in topological
570    /// order. The planner never emits this entry as one raw SQL step.
571    ReplaceWithCascade {
572        /// The desired type (source).
573        source: UserType,
574        /// The existing type (catalog / live database).
575        catalog: UserType,
576    },
577}
578
579/// A structural change to a single view.
580#[allow(clippy::large_enum_variant)]
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
583pub enum ViewChange {
584    /// Create a new view.
585    Create(View),
586    /// Drop an existing view.
587    Drop(QualifiedName),
588    /// Replace the SELECT body of a view.
589    ///
590    /// `compatible` is `true` when Postgres's `CREATE OR REPLACE VIEW` rules
591    /// are satisfied (same column names and types at the same indexes, with
592    /// new columns only appended at the end). When `compatible` is `false`,
593    /// the planner must `DROP` then re-`CREATE` the view (and rebuild its
594    /// dependents).
595    ReplaceBody {
596        /// The view as it should exist (source SQL).
597        source: View,
598        /// The view as it currently exists (live catalog).
599        catalog: View,
600        /// Whether `CREATE OR REPLACE VIEW` can be used (`true`) or a
601        /// `DROP` + `CREATE` cycle is required (`false`).
602        compatible: bool,
603    },
604    /// Change `WITH (security_barrier = ..., security_invoker = ...)` reloptions.
605    SetReloption {
606        /// View qname.
607        qname: QualifiedName,
608        /// Desired `security_barrier` value (`None` clears the option).
609        security_barrier: Option<bool>,
610        /// Desired `security_invoker` value (`None` clears the option).
611        security_invoker: Option<bool>,
612    },
613    /// Set (or clear) the view-level `COMMENT ON VIEW`.
614    SetComment {
615        /// View qname.
616        qname: QualifiedName,
617        /// New comment (`None` clears the comment).
618        comment: Option<String>,
619    },
620    /// Set (or clear) a `COMMENT ON COLUMN view.col`.
621    SetColumnComment {
622        /// View qname.
623        qname: QualifiedName,
624        /// Column name.
625        column: Identifier,
626        /// New comment (`None` clears the comment).
627        comment: Option<String>,
628    },
629}
630
631/// A structural change to a single materialized view.
632#[allow(clippy::large_enum_variant)]
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
635pub enum MvChange {
636    /// Create a new materialized view.
637    Create(MaterializedView),
638    /// Drop an existing materialized view.
639    ///
640    /// Unlike `ViewChange::Drop`, this is classified as `Safe` because
641    /// materialized views are derived data that can be recreated by refreshing.
642    Drop(QualifiedName),
643    /// Replace the SELECT body of a materialized view.
644    ///
645    /// Materialized views do not support `CREATE OR REPLACE`; the planner must
646    /// always `DROP` then `CREATE` the MV (and rebuild any indexes on it).
647    ReplaceBody {
648        /// The MV as it should exist (source SQL).
649        source: MaterializedView,
650        /// The MV as it currently exists (live catalog).
651        catalog: MaterializedView,
652    },
653    /// Set (or clear) the MV-level `COMMENT ON MATERIALIZED VIEW`.
654    SetComment {
655        /// MV qname.
656        qname: QualifiedName,
657        /// New comment (`None` clears the comment).
658        comment: Option<String>,
659    },
660    /// Set (or clear) a `COMMENT ON COLUMN mv.col`.
661    SetColumnComment {
662        /// MV qname.
663        qname: QualifiedName,
664        /// Column name.
665        column: Identifier,
666        /// New comment (`None` clears the comment).
667        comment: Option<String>,
668    },
669}
670
671/// A structural change to a single user-defined function.
672#[allow(clippy::large_enum_variant)]
673#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
674#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
675pub enum FunctionChange {
676    /// Create a new function.
677    Create(Function),
678    /// Drop an existing function by qualified name and normalized arg types.
679    Drop {
680        /// Qualified name of the function.
681        qname: QualifiedName,
682        /// Normalized argument types (IN/INOUT/VARIADIC only) used to
683        /// disambiguate overloads in the DROP FUNCTION statement.
684        args: NormalizedArgTypes,
685    },
686    /// Replace the function body and/or attributes using `CREATE OR REPLACE
687    /// FUNCTION`. Only valid when `function_can_or_replace` returns `true`.
688    CreateOrReplace(Function),
689    /// The function's return type or language changed in a way that PG's
690    /// `CREATE OR REPLACE FUNCTION` rejects. The planner must emit
691    /// `DROP FUNCTION … CASCADE` followed by `CREATE FUNCTION`.
692    ReplaceWithCascade {
693        /// The desired function (source).
694        source: Function,
695        /// The existing function (catalog / live database).
696        catalog: Function,
697    },
698    /// Set (or clear) `COMMENT ON FUNCTION`.
699    SetComment {
700        /// Qualified name of the function.
701        qname: QualifiedName,
702        /// Normalized argument types for disambiguation.
703        args: NormalizedArgTypes,
704        /// New comment (`None` clears the comment).
705        comment: Option<String>,
706    },
707}
708
709/// A structural change to a single user-defined procedure.
710#[allow(clippy::large_enum_variant)]
711#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
712#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
713pub enum ProcedureChange {
714    /// Create a new procedure.
715    Create(Procedure),
716    /// Drop an existing procedure by qualified name.
717    Drop(QualifiedName),
718    /// Replace the procedure body and/or attributes using `CREATE OR REPLACE
719    /// PROCEDURE`.
720    CreateOrReplace(Procedure),
721    /// Set (or clear) `COMMENT ON PROCEDURE`.
722    SetComment {
723        /// Qualified name of the procedure.
724        qname: QualifiedName,
725        /// New comment (`None` clears the comment).
726        comment: Option<String>,
727    },
728}
729
730/// Change to one extension. Pair-by-name semantics.
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
733pub enum ExtensionChange {
734    /// Install a new extension.
735    Create(Extension),
736    /// Drop an extension by name. Emits `DROP EXTENSION ... CASCADE`.
737    Drop(Identifier),
738    /// Bump extension version: `ALTER EXTENSION ... UPDATE TO 'v'`.
739    AlterUpdate {
740        /// Extension name.
741        name: Identifier,
742        /// New version to update to.
743        to_version: String,
744    },
745    /// Schema-changing replace (DROP CASCADE + CREATE).
746    ReplaceWithCascade(Extension),
747    /// Change the `COMMENT ON EXTENSION` text.
748    CommentOn {
749        /// Extension name.
750        name: Identifier,
751        /// New comment value (`None` clears the comment).
752        comment: Option<String>,
753    },
754}
755
756/// Change to one trigger. Pair-by-qname semantics; any structural
757/// difference emits `Replace`.
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
760pub enum TriggerChange {
761    /// Install a new trigger.
762    Create(Trigger),
763    /// Drop a trigger by qname (needs the table for `DROP TRIGGER name ON table`).
764    Drop {
765        /// Qualified name of the trigger.
766        qname: QualifiedName,
767        /// Owning table (needed for `DROP TRIGGER name ON table`).
768        table: QualifiedName,
769    },
770    /// Any structural change: drop + create.
771    Replace(Trigger),
772    /// Change the `COMMENT ON TRIGGER` text.
773    CommentOn {
774        /// Qualified name of the trigger.
775        qname: QualifiedName,
776        /// Owning table (needed for `COMMENT ON TRIGGER name ON table`).
777        table: QualifiedName,
778        /// New comment value (`None` clears the comment).
779        comment: Option<String>,
780    },
781}
782
783/// A partition-membership change on a child table.
784///
785/// These changes are emitted by the differ when the `partition_of` field of a
786/// table changes. Wiring to SQL steps lands in PART9.
787#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
788#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
789pub enum TableChange {
790    /// Attach a child table to a parent partitioned table.
791    ///
792    /// Corresponds to `ALTER TABLE <parent> ATTACH PARTITION <child> <bounds>`.
793    AttachPartition {
794        /// The partitioned parent table.
795        parent: QualifiedName,
796        /// The child table being attached.
797        child: QualifiedName,
798        /// The partition bounds clause.
799        bounds: PartitionBounds,
800    },
801    /// Detach a child table from a parent partitioned table.
802    ///
803    /// Corresponds to `ALTER TABLE <parent> DETACH PARTITION <child>`.
804    DetachPartition {
805        /// The partitioned parent table.
806        parent: QualifiedName,
807        /// The child table being detached.
808        child: QualifiedName,
809    },
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use crate::identifier::Identifier;
816    use crate::ir::column::Column;
817    use crate::ir::column_type::ColumnType;
818
819    fn id(s: &str) -> Identifier {
820        Identifier::from_unquoted(s).unwrap()
821    }
822
823    fn qn(schema: &str, name: &str) -> QualifiedName {
824        QualifiedName::new(id(schema), id(name))
825    }
826
827    fn table_users() -> Table {
828        Table {
829            qname: qn("app", "users"),
830            columns: vec![Column {
831                name: id("id"),
832                ty: ColumnType::BigInt,
833                nullable: false,
834                default: None,
835                identity: None,
836                generated: None,
837                collation: None,
838                storage: None,
839                compression: None,
840                comment: None,
841            }],
842            constraints: vec![],
843            partition_by: None,
844            partition_of: None,
845            comment: None,
846            owner: None,
847            grants: vec![],
848            rls_enabled: false,
849            rls_forced: false,
850            policies: vec![],
851            storage: crate::ir::reloptions::TableStorageOptions::default(),
852        }
853    }
854
855    #[test]
856    fn create_table_entry_serde_round_trip() {
857        let entry = ChangeEntry {
858            change: Change::CreateTable(table_users()),
859            destructiveness: Destructiveness::Safe,
860        };
861        let json = serde_json::to_string(&entry).unwrap();
862        let back: ChangeEntry = serde_json::from_str(&json).unwrap();
863        assert_eq!(entry, back);
864    }
865
866    #[test]
867    fn drop_table_entry_serde_round_trip() {
868        let entry = ChangeEntry {
869            change: Change::DropTable {
870                qname: qn("app", "users"),
871                row_count_estimate: None,
872            },
873            destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
874                reason: "drops table app.users".into(),
875            },
876        };
877        let json = serde_json::to_string(&entry).unwrap();
878        let back: ChangeEntry = serde_json::from_str(&json).unwrap();
879        assert_eq!(entry, back);
880    }
881
882    #[test]
883    fn equal_create_table_changes_compare_equal() {
884        let a = Change::CreateTable(table_users());
885        let b = Change::CreateTable(table_users());
886        assert_eq!(a, b);
887    }
888}