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