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