Skip to main content

pgevolve_core/diff/
change.rs

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