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