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