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