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    /// A change that cannot be performed in-place.
353    ///
354    /// Emitted by the differ when it detects a structural difference that has
355    /// no safe automatic migration path (e.g., changing a table's `PARTITION BY`
356    /// clause). The ordering phase converts this into a [`PlanError`] so the
357    /// plan never reaches execution.
358    ///
359    /// [`PlanError`]: crate::plan::error::PlanError
360    UnsupportedDiff {
361        /// Human-readable explanation of what changed and why it cannot be
362        /// performed in-place.
363        reason: String,
364    },
365}
366
367// Silence the unused-import lint — OwnerObjectKind and GrantTarget are used in
368// the variants above but Rust's lint fires on the `use` line when all uses are
369// inside the enum definition.
370const _: () = {
371    let _ = core::mem::size_of::<OwnerObjectKind>();
372    let _ = core::mem::size_of::<GrantTarget>();
373};
374
375/// A structural change to a single user-defined type.
376#[allow(clippy::large_enum_variant)]
377#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
379pub enum UserTypeChange {
380    /// Create a new user-defined type.
381    Create(UserType),
382    /// Drop a user-defined type by qualified name.
383    Drop(QualifiedName),
384
385    /// Add a new label to an existing enum type.
386    EnumAddValue {
387        /// Qualified name of the enum type.
388        qname: QualifiedName,
389        /// The new label string.
390        value: String,
391        /// If `Some`, the new value is placed immediately before this label.
392        before: Option<String>,
393        /// If `Some`, the new value is placed immediately after this label.
394        after: Option<String>,
395    },
396    /// Rename an existing enum label.
397    EnumRenameValue {
398        /// Qualified name of the enum type.
399        qname: QualifiedName,
400        /// The existing label to rename.
401        from: String,
402        /// The new label name.
403        to: String,
404    },
405
406    /// Add a CHECK constraint to a domain.
407    DomainAddCheck {
408        /// Qualified name of the domain type.
409        qname: QualifiedName,
410        /// The constraint to add.
411        constraint: DomainCheck,
412    },
413    /// Drop a named CHECK constraint from a domain.
414    DomainDropCheck {
415        /// Qualified name of the domain type.
416        qname: QualifiedName,
417        /// The constraint name to drop.
418        name: Identifier,
419    },
420    /// Set (or clear) the DEFAULT expression on a domain.
421    DomainSetDefault {
422        /// Qualified name of the domain type.
423        qname: QualifiedName,
424        /// New default expression (`None` clears the default).
425        default: Option<NormalizedExpr>,
426    },
427    /// Toggle the `NOT NULL` constraint on a domain.
428    DomainSetNotNull {
429        /// Qualified name of the domain type.
430        qname: QualifiedName,
431        /// `true` means NOT NULL (i.e., `nullable = false`).
432        not_null: bool,
433    },
434
435    /// Add a new attribute to a composite type.
436    CompositeAddAttribute {
437        /// Qualified name of the composite type.
438        qname: QualifiedName,
439        /// The attribute to add.
440        attribute: CompositeAttribute,
441    },
442    /// Drop an attribute from a composite type.
443    CompositeDropAttribute {
444        /// Qualified name of the composite type.
445        qname: QualifiedName,
446        /// The attribute name to drop.
447        name: Identifier,
448    },
449    /// Change the type of an existing composite attribute.
450    CompositeAlterAttributeType {
451        /// Qualified name of the composite type.
452        qname: QualifiedName,
453        /// The attribute name whose type is being changed.
454        attribute: Identifier,
455        /// The new column type.
456        new_type: ColumnType,
457    },
458
459    /// Set (or clear) the `COMMENT ON TYPE` for this type.
460    SetComment {
461        /// Qualified name of the type.
462        qname: QualifiedName,
463        /// New comment (`None` clears the comment).
464        comment: Option<String>,
465    },
466
467    /// Emitted when the requested change cannot be done in place via `ALTER`.
468    ///
469    /// T8's cascade walker appends `ReplaceBody` for any views/MVs that depend
470    /// on the type so they are recreated after the type is rebuilt. T9's SQL
471    /// emitter expands this single entry into `DROP TYPE … CASCADE` plus
472    /// `CREATE TYPE`, then the recreated dependents follow in topological
473    /// order. The planner never emits this entry as one raw SQL step.
474    ReplaceWithCascade {
475        /// The desired type (source).
476        source: UserType,
477        /// The existing type (catalog / live database).
478        catalog: UserType,
479    },
480}
481
482/// A structural change to a single view.
483#[allow(clippy::large_enum_variant)]
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
486pub enum ViewChange {
487    /// Create a new view.
488    Create(View),
489    /// Drop an existing view.
490    Drop(QualifiedName),
491    /// Replace the SELECT body of a view.
492    ///
493    /// `compatible` is `true` when Postgres's `CREATE OR REPLACE VIEW` rules
494    /// are satisfied (same column names and types at the same indexes, with
495    /// new columns only appended at the end). When `compatible` is `false`,
496    /// the planner must `DROP` then re-`CREATE` the view (and rebuild its
497    /// dependents).
498    ReplaceBody {
499        /// The view as it should exist (source SQL).
500        source: View,
501        /// The view as it currently exists (live catalog).
502        catalog: View,
503        /// Whether `CREATE OR REPLACE VIEW` can be used (`true`) or a
504        /// `DROP` + `CREATE` cycle is required (`false`).
505        compatible: bool,
506    },
507    /// Change `WITH (security_barrier = ..., security_invoker = ...)` reloptions.
508    SetReloption {
509        /// View qname.
510        qname: QualifiedName,
511        /// Desired `security_barrier` value (`None` clears the option).
512        security_barrier: Option<bool>,
513        /// Desired `security_invoker` value (`None` clears the option).
514        security_invoker: Option<bool>,
515    },
516    /// Set (or clear) the view-level `COMMENT ON VIEW`.
517    SetComment {
518        /// View qname.
519        qname: QualifiedName,
520        /// New comment (`None` clears the comment).
521        comment: Option<String>,
522    },
523    /// Set (or clear) a `COMMENT ON COLUMN view.col`.
524    SetColumnComment {
525        /// View qname.
526        qname: QualifiedName,
527        /// Column name.
528        column: Identifier,
529        /// New comment (`None` clears the comment).
530        comment: Option<String>,
531    },
532}
533
534/// A structural change to a single materialized view.
535#[allow(clippy::large_enum_variant)]
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
538pub enum MvChange {
539    /// Create a new materialized view.
540    Create(MaterializedView),
541    /// Drop an existing materialized view.
542    ///
543    /// Unlike `ViewChange::Drop`, this is classified as `Safe` because
544    /// materialized views are derived data that can be recreated by refreshing.
545    Drop(QualifiedName),
546    /// Replace the SELECT body of a materialized view.
547    ///
548    /// Materialized views do not support `CREATE OR REPLACE`; the planner must
549    /// always `DROP` then `CREATE` the MV (and rebuild any indexes on it).
550    ReplaceBody {
551        /// The MV as it should exist (source SQL).
552        source: MaterializedView,
553        /// The MV as it currently exists (live catalog).
554        catalog: MaterializedView,
555    },
556    /// Set (or clear) the MV-level `COMMENT ON MATERIALIZED VIEW`.
557    SetComment {
558        /// MV qname.
559        qname: QualifiedName,
560        /// New comment (`None` clears the comment).
561        comment: Option<String>,
562    },
563    /// Set (or clear) a `COMMENT ON COLUMN mv.col`.
564    SetColumnComment {
565        /// MV qname.
566        qname: QualifiedName,
567        /// Column name.
568        column: Identifier,
569        /// New comment (`None` clears the comment).
570        comment: Option<String>,
571    },
572}
573
574/// A structural change to a single user-defined function.
575#[allow(clippy::large_enum_variant)]
576#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
577#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
578pub enum FunctionChange {
579    /// Create a new function.
580    Create(Function),
581    /// Drop an existing function by qualified name and normalized arg types.
582    Drop {
583        /// Qualified name of the function.
584        qname: QualifiedName,
585        /// Normalized argument types (IN/INOUT/VARIADIC only) used to
586        /// disambiguate overloads in the DROP FUNCTION statement.
587        args: NormalizedArgTypes,
588    },
589    /// Replace the function body and/or attributes using `CREATE OR REPLACE
590    /// FUNCTION`. Only valid when `function_can_or_replace` returns `true`.
591    CreateOrReplace(Function),
592    /// The function's return type or language changed in a way that PG's
593    /// `CREATE OR REPLACE FUNCTION` rejects. The planner must emit
594    /// `DROP FUNCTION … CASCADE` followed by `CREATE FUNCTION`.
595    ReplaceWithCascade {
596        /// The desired function (source).
597        source: Function,
598        /// The existing function (catalog / live database).
599        catalog: Function,
600    },
601    /// Set (or clear) `COMMENT ON FUNCTION`.
602    SetComment {
603        /// Qualified name of the function.
604        qname: QualifiedName,
605        /// Normalized argument types for disambiguation.
606        args: NormalizedArgTypes,
607        /// New comment (`None` clears the comment).
608        comment: Option<String>,
609    },
610}
611
612/// A structural change to a single user-defined procedure.
613#[allow(clippy::large_enum_variant)]
614#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
615#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
616pub enum ProcedureChange {
617    /// Create a new procedure.
618    Create(Procedure),
619    /// Drop an existing procedure by qualified name.
620    Drop(QualifiedName),
621    /// Replace the procedure body and/or attributes using `CREATE OR REPLACE
622    /// PROCEDURE`.
623    CreateOrReplace(Procedure),
624    /// Set (or clear) `COMMENT ON PROCEDURE`.
625    SetComment {
626        /// Qualified name of the procedure.
627        qname: QualifiedName,
628        /// New comment (`None` clears the comment).
629        comment: Option<String>,
630    },
631}
632
633/// Change to one extension. Pair-by-name semantics.
634#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
635#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
636pub enum ExtensionChange {
637    /// Install a new extension.
638    Create(Extension),
639    /// Drop an extension by name. Emits `DROP EXTENSION ... CASCADE`.
640    Drop(Identifier),
641    /// Bump extension version: `ALTER EXTENSION ... UPDATE TO 'v'`.
642    AlterUpdate {
643        /// Extension name.
644        name: Identifier,
645        /// New version to update to.
646        to_version: String,
647    },
648    /// Schema-changing replace (DROP CASCADE + CREATE).
649    ReplaceWithCascade(Extension),
650    /// Change the `COMMENT ON EXTENSION` text.
651    CommentOn {
652        /// Extension name.
653        name: Identifier,
654        /// New comment value (`None` clears the comment).
655        comment: Option<String>,
656    },
657}
658
659/// Change to one trigger. Pair-by-qname semantics; any structural
660/// difference emits `Replace`.
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
662#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
663pub enum TriggerChange {
664    /// Install a new trigger.
665    Create(Trigger),
666    /// Drop a trigger by qname (needs the table for `DROP TRIGGER name ON table`).
667    Drop {
668        /// Qualified name of the trigger.
669        qname: QualifiedName,
670        /// Owning table (needed for `DROP TRIGGER name ON table`).
671        table: QualifiedName,
672    },
673    /// Any structural change: drop + create.
674    Replace(Trigger),
675    /// Change the `COMMENT ON TRIGGER` text.
676    CommentOn {
677        /// Qualified name of the trigger.
678        qname: QualifiedName,
679        /// Owning table (needed for `COMMENT ON TRIGGER name ON table`).
680        table: QualifiedName,
681        /// New comment value (`None` clears the comment).
682        comment: Option<String>,
683    },
684}
685
686/// A partition-membership change on a child table.
687///
688/// These changes are emitted by the differ when the `partition_of` field of a
689/// table changes. Wiring to SQL steps lands in PART9.
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
692pub enum TableChange {
693    /// Attach a child table to a parent partitioned table.
694    ///
695    /// Corresponds to `ALTER TABLE <parent> ATTACH PARTITION <child> <bounds>`.
696    AttachPartition {
697        /// The partitioned parent table.
698        parent: QualifiedName,
699        /// The child table being attached.
700        child: QualifiedName,
701        /// The partition bounds clause.
702        bounds: PartitionBounds,
703    },
704    /// Detach a child table from a parent partitioned table.
705    ///
706    /// Corresponds to `ALTER TABLE <parent> DETACH PARTITION <child>`.
707    DetachPartition {
708        /// The partitioned parent table.
709        parent: QualifiedName,
710        /// The child table being detached.
711        child: QualifiedName,
712    },
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::identifier::Identifier;
719    use crate::ir::column::Column;
720    use crate::ir::column_type::ColumnType;
721
722    fn id(s: &str) -> Identifier {
723        Identifier::from_unquoted(s).unwrap()
724    }
725
726    fn qn(schema: &str, name: &str) -> QualifiedName {
727        QualifiedName::new(id(schema), id(name))
728    }
729
730    fn table_users() -> Table {
731        Table {
732            qname: qn("app", "users"),
733            columns: vec![Column {
734                name: id("id"),
735                ty: ColumnType::BigInt,
736                nullable: false,
737                default: None,
738                identity: None,
739                generated: None,
740                collation: None,
741                storage: None,
742                compression: None,
743                comment: None,
744            }],
745            constraints: vec![],
746            partition_by: None,
747            partition_of: None,
748            comment: None,
749            owner: None,
750            grants: vec![],
751            rls_enabled: false,
752            rls_forced: false,
753            policies: vec![],
754            storage: crate::ir::reloptions::TableStorageOptions::default(),
755        }
756    }
757
758    #[test]
759    fn create_table_entry_serde_round_trip() {
760        let entry = ChangeEntry {
761            change: Change::CreateTable(table_users()),
762            destructiveness: Destructiveness::Safe,
763        };
764        let json = serde_json::to_string(&entry).unwrap();
765        let back: ChangeEntry = serde_json::from_str(&json).unwrap();
766        assert_eq!(entry, back);
767    }
768
769    #[test]
770    fn drop_table_entry_serde_round_trip() {
771        let entry = ChangeEntry {
772            change: Change::DropTable {
773                qname: qn("app", "users"),
774                row_count_estimate: None,
775            },
776            destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
777                reason: "drops table app.users".into(),
778            },
779        };
780        let json = serde_json::to_string(&entry).unwrap();
781        let back: ChangeEntry = serde_json::from_str(&json).unwrap();
782        assert_eq!(entry, back);
783    }
784
785    #[test]
786    fn equal_create_table_changes_compare_equal() {
787        let a = Change::CreateTable(table_users());
788        let b = Change::CreateTable(table_users());
789        assert_eq!(a, b);
790    }
791}