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