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