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