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