razeri_plugin_contract/evolution.rs
1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **Changing a table that already holds rows** — what this engine keeps
6//! through such a change, and the statement it takes to make one.
7//!
8//! A first deploy writes `CREATE`. Every deploy after it has rows to keep, and
9//! the two questions a dialect is the only one able to answer are:
10//!
11//! - **does the engine keep every value through this change?** A column
12//! declared to hold ten characters and now declared to hold forty holds
13//! everything it held on one engine and is rebuilt by a cast on another.
14//! [`EvolutionAnswers`] is where a dialect says which it is.
15//! - **what statement does the engine take?** Most write
16//! `ALTER TABLE … ADD COLUMN …`, some write `ALTER TABLE … ADD …`, and a
17//! change some engines make with a statement is not a statement other engines
18//! have at all. [`ColumnEvolution`] is the change, handed over to be spelled.
19//!
20//! # Both default to the careful answer
21//!
22//! An engine nobody has measured keeps nothing it has not been said to keep,
23//! and is given no statement Razeri cannot show it accepts. A dialect that
24//! answers nothing here still gets `ADD COLUMN`, the spelling most engines
25//! take, and gets no other statement at all. An engine that writes `ADD`
26//! without `COLUMN` overrides
27//! [`DdlRenderer::render_column_evolution`](crate::DdlRenderer::render_column_evolution).
28
29use crate::data_type::DataType;
30
31/// **What this engine keeps through a change to a table that already holds
32/// rows.**
33///
34/// Every answer defaults to `false`, which means *nothing has been measured*
35/// rather than *this engine loses data*. Razeri's classifier treats a change it
36/// cannot prove keeps every value as one that does not, so a silence here costs
37/// an operator a warning they can read and never costs them a row.
38///
39/// An answer only ever makes a verdict gentler. Razeri proves what it can from
40/// the types themselves first; these answers are asked afterwards, about the
41/// changes the engine's own spelling left unprovable.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43#[non_exhaustive]
44pub struct EvolutionAnswers {
45 /// **This engine widens a string's declared length in place, keeping every
46 /// value.**
47 ///
48 /// A column declared `varchar(10)` and now declared `varchar(40)` holds a
49 /// strict superset of what it held, as a type. Whether the engine gets it
50 /// there without rewriting the column is the engine's own business, and
51 /// this is where it says so.
52 ///
53 /// The question is asked at all because a length is spelled differently on
54 /// every engine — `VARCHAR(40)` on one, `STRING(40)` on another,
55 /// `NVARCHAR(40)` on a third — and the length in a spelling Razeri does not
56 /// write is a length Razeri cannot read back. A `true` here is the dialect
57 /// saying *"that pair is a widening on my engine, and nothing is lost"*.
58 pub parameterized_string_widening_is_lossless: bool,
59
60 /// **This engine adds a field to a record a table already holds, keeping
61 /// every value.**
62 ///
63 /// The change is a record column that gained a field and changed in no
64 /// other way. Some engines take it as a change to the column's declared
65 /// shape and keep every row; others have no way to make it at all, and the
66 /// column has to be rebuilt.
67 ///
68 /// `false` is the careful answer, and it is the right one for every engine
69 /// that has no records to begin with.
70 pub nested_field_addition_is_lossless: bool,
71}
72
73impl EvolutionAnswers {
74 /// **Nothing measured** — the answer for an engine nobody has asked.
75 #[must_use]
76 pub const fn none() -> Self {
77 Self {
78 parameterized_string_widening_is_lossless: false,
79 nested_field_addition_is_lossless: false,
80 }
81 }
82
83 /// **This engine widens a string's declared length in place.** See
84 /// [`EvolutionAnswers::parameterized_string_widening_is_lossless`].
85 #[must_use]
86 pub const fn widening_a_string_in_place() -> Self {
87 Self {
88 parameterized_string_widening_is_lossless: true,
89 nested_field_addition_is_lossless: false,
90 }
91 }
92
93 /// The same answers with the nested-field one added, so an engine that
94 /// keeps both states both without a constructor per pair.
95 #[must_use]
96 pub const fn and_a_nested_field(self) -> Self {
97 Self { nested_field_addition_is_lossless: true, ..self }
98 }
99}
100
101/// **One change to a table that already holds rows**, handed to the dialect to
102/// spell.
103///
104/// The schema and table arrive unqualified and unquoted, because the dialect
105/// knows how it qualifies and quotes them. The type arrives as a
106/// [`DataType`] — Razeri's own, so the dialect renders it the way it renders
107/// every other column of that type, and the statement cannot disagree with the
108/// `CREATE` that would have built the same column.
109#[derive(Debug, Clone, Copy)]
110#[non_exhaustive]
111pub enum ColumnEvolution<'a> {
112 /// A column the table does not have yet, which accepts no value today.
113 AddColumn {
114 /// The table's schema, absent on engines that have none.
115 schema: Option<&'a str>,
116 /// The table.
117 table: &'a str,
118 /// The new column.
119 column: &'a str,
120 /// The type it is declared with.
121 data_type: &'a DataType,
122 },
123
124 /// A column whose declared type now holds more than it held.
125 WidenColumn {
126 /// The table's schema, absent on engines that have none.
127 schema: Option<&'a str>,
128 /// The table.
129 table: &'a str,
130 /// The column.
131 column: &'a str,
132 /// The type it is declared with now.
133 data_type: &'a DataType,
134 },
135
136 /// A record column that gained a field, and gained nothing else.
137 AddNestedField {
138 /// The table's schema, absent on engines that have none.
139 schema: Option<&'a str>,
140 /// The table.
141 table: &'a str,
142 /// The column whose record grew.
143 column: &'a str,
144 /// The path from the column to the new field, dotted through the
145 /// records that contain it — for a dialect whose engine names the field
146 /// rather than restating the column.
147 field: &'a str,
148 /// **The column's whole type, with the new field in it.** An engine
149 /// that makes this change by restating the column's shape needs all of
150 /// it; an engine that names the field alone reads `field` instead.
151 data_type: &'a DataType,
152 },
153}
154
155impl ColumnEvolution<'_> {
156 /// The table's schema, whichever change this is.
157 #[must_use]
158 pub const fn schema(&self) -> Option<&str> {
159 match self {
160 Self::AddColumn { schema, .. }
161 | Self::WidenColumn { schema, .. }
162 | Self::AddNestedField { schema, .. } => *schema,
163 }
164 }
165
166 /// The table, whichever change this is.
167 #[must_use]
168 pub const fn table(&self) -> &str {
169 match self {
170 Self::AddColumn { table, .. }
171 | Self::WidenColumn { table, .. }
172 | Self::AddNestedField { table, .. } => table,
173 }
174 }
175
176 /// The column, whichever change this is.
177 #[must_use]
178 pub const fn column(&self) -> &str {
179 match self {
180 Self::AddColumn { column, .. }
181 | Self::WidenColumn { column, .. }
182 | Self::AddNestedField { column, .. } => column,
183 }
184 }
185
186 /// The type the column is declared with after the change.
187 #[must_use]
188 pub const fn data_type(&self) -> &DataType {
189 match self {
190 Self::AddColumn { data_type, .. }
191 | Self::WidenColumn { data_type, .. }
192 | Self::AddNestedField { data_type, .. } => data_type,
193 }
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn nothing_is_claimed_until_a_dialect_claims_it() {
203 let silent = EvolutionAnswers::default();
204 assert!(!silent.parameterized_string_widening_is_lossless);
205 assert!(!silent.nested_field_addition_is_lossless);
206 assert_eq!(silent, EvolutionAnswers::none());
207 }
208
209 #[test]
210 fn an_engine_that_keeps_both_states_both() {
211 let both = EvolutionAnswers::widening_a_string_in_place().and_a_nested_field();
212 assert!(both.parameterized_string_widening_is_lossless);
213 assert!(both.nested_field_addition_is_lossless);
214 }
215
216 #[test]
217 fn a_change_names_its_table_and_column_whichever_change_it_is() {
218 let data_type = DataType::boolean();
219 let change = ColumnEvolution::AddColumn {
220 schema: Some("telemetry_core"),
221 table: "core_clients_daily",
222 column: "is_default_browser",
223 data_type: &data_type,
224 };
225 assert_eq!(change.schema(), Some("telemetry_core"));
226 assert_eq!(change.table(), "core_clients_daily");
227 assert_eq!(change.column(), "is_default_browser");
228 }
229}