Skip to main content

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)]
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    /// **This engine drops a column where the table stands, even when a
73    /// column of one of the table's indexes comes after it.**
74    ///
75    /// Most engines do, and the answer **defaults to `true`**: unlike the two
76    /// answers above, the careful answer here is the plain drop, because an
77    /// engine that cannot make it refuses it with nothing changed. One engine
78    /// has been measured to refuse — `DuckDB`: *"Cannot drop this column: an
79    /// index depends on a column after it!"*, and a table's key is such an
80    /// index. Answer `false` for an engine like it, and Razeri rebuilds such a
81    /// table instead: renamed aside, made again from the new design's
82    /// `CREATE`, every row copied across, the old one dropped.
83    pub drops_a_column_in_place_before_an_index_column: bool,
84}
85
86impl Default for EvolutionAnswers {
87    fn default() -> Self {
88        Self::none()
89    }
90}
91
92impl EvolutionAnswers {
93    /// **Nothing measured** — the answer for an engine nobody has asked.
94    #[must_use]
95    pub const fn none() -> Self {
96        Self {
97            parameterized_string_widening_is_lossless: false,
98            nested_field_addition_is_lossless: false,
99            drops_a_column_in_place_before_an_index_column: true,
100        }
101    }
102
103    /// **This engine widens a string's declared length in place.** See
104    /// [`EvolutionAnswers::parameterized_string_widening_is_lossless`].
105    #[must_use]
106    pub const fn widening_a_string_in_place() -> Self {
107        Self { parameterized_string_widening_is_lossless: true, ..Self::none() }
108    }
109
110    /// The same answers with the nested-field one added, so an engine that
111    /// keeps both states both without a constructor per pair.
112    #[must_use]
113    pub const fn and_a_nested_field(self) -> Self {
114        Self { nested_field_addition_is_lossless: true, ..self }
115    }
116
117    /// The same answers, for an engine that **cannot drop a column that comes
118    /// before a column of one of the table's indexes**. See
119    /// [`EvolutionAnswers::drops_a_column_in_place_before_an_index_column`].
120    #[must_use]
121    pub const fn but_no_drop_before_an_index_column(self) -> Self {
122        Self { drops_a_column_in_place_before_an_index_column: false, ..self }
123    }
124}
125
126/// **An object a statement names, spelled the way the statement writes it**:
127/// its schema as a container is written, its own name through the dialect's
128/// quoting, and the two qualified together.
129///
130/// A statement that runs only where the engine's catalogue says it should
131/// needs both the written form, to run, and the parts, to look up; an engine
132/// that folds a plain name to capitals holds it in its catalogue that way, and
133/// a quoted one as written.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135#[non_exhaustive]
136pub struct WrittenObject<'a> {
137    /// The schema, as the statement writes it, where the object names one.
138    pub schema: Option<&'a str>,
139    /// The object's own name, as the statement writes it — quoted where the
140    /// dialect quotes.
141    pub name: &'a str,
142    /// The schema and the name, qualified as the statement writes them.
143    pub relation: &'a str,
144}
145
146impl<'a> WrittenObject<'a> {
147    /// The object, from its three written forms.
148    #[must_use]
149    pub const fn new(schema: Option<&'a str>, name: &'a str, relation: &'a str) -> Self {
150        Self { schema, name, relation }
151    }
152}
153
154/// **A statement a dialect wrote, and how it ends.**
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[non_exhaustive]
157pub enum Written {
158    /// A statement the caller ends the way it ends every other statement in
159    /// the file — a `;`, and the engine's batch separator where it has one.
160    Statement(String),
161    /// A block that carries its own end, such as a PL/SQL block and the `/`
162    /// line that runs it in `SQL*Plus`. The caller adds nothing but a line
163    /// break.
164    Block(String),
165}
166
167impl Written {
168    /// The text, however it ends.
169    #[must_use]
170    pub fn text(&self) -> &str {
171        match self {
172            Self::Statement(text) | Self::Block(text) => text,
173        }
174    }
175}
176
177/// **One change to a table that already holds rows**, handed to the dialect to
178/// spell.
179///
180/// The schema and table arrive unqualified and unquoted, because the dialect
181/// knows how it qualifies and quotes them. The type arrives as a
182/// [`DataType`] — Razeri's own, so the dialect renders it the way it renders
183/// every other column of that type, and the statement cannot disagree with the
184/// `CREATE` that would have built the same column.
185#[derive(Debug, Clone, Copy)]
186#[non_exhaustive]
187pub enum ColumnEvolution<'a> {
188    /// A column the table does not have yet, which accepts no value today.
189    AddColumn {
190        /// The table's schema, absent on engines that have none.
191        schema: Option<&'a str>,
192        /// The table.
193        table: &'a str,
194        /// The new column.
195        column: &'a str,
196        /// The type it is declared with.
197        data_type: &'a DataType,
198    },
199
200    /// A column whose declared type now holds more than it held.
201    WidenColumn {
202        /// The table's schema, absent on engines that have none.
203        schema: Option<&'a str>,
204        /// The table.
205        table: &'a str,
206        /// The column.
207        column: &'a str,
208        /// The type it is declared with now.
209        data_type: &'a DataType,
210    },
211
212    /// A record column that gained a field, and gained nothing else.
213    AddNestedField {
214        /// The table's schema, absent on engines that have none.
215        schema: Option<&'a str>,
216        /// The table.
217        table: &'a str,
218        /// The column whose record grew.
219        column: &'a str,
220        /// The path from the column to the new field, dotted through the
221        /// records that contain it — for a dialect whose engine names the field
222        /// rather than restating the column.
223        field: &'a str,
224        /// **The column's whole type, with the new field in it.** An engine
225        /// that makes this change by restating the column's shape needs all of
226        /// it; an engine that names the field alone reads `field` instead.
227        data_type: &'a DataType,
228    },
229
230    /// **A column that may no longer be left empty**, asked once every row
231    /// the table holds has a value in it.
232    ///
233    /// A new column a design requires is added without its `NOT NULL`,
234    /// because the rows a table already holds have no value to give it. Once
235    /// they do, this is the step that requires it: `ALTER TABLE … ALTER
236    /// COLUMN … SET NOT NULL` on some engines, `MODIFY … NOT NULL` with the
237    /// type restated on others. `data_type` is the column's type, for an
238    /// engine that restates it.
239    RequireColumn {
240        /// The table's schema, absent on engines that have none.
241        schema: Option<&'a str>,
242        /// The table.
243        table: &'a str,
244        /// The column.
245        column: &'a str,
246        /// The type it is declared with.
247        data_type: &'a DataType,
248    },
249}
250
251impl ColumnEvolution<'_> {
252    /// The table's schema, whichever change this is.
253    #[must_use]
254    pub const fn schema(&self) -> Option<&str> {
255        match self {
256            Self::AddColumn { schema, .. }
257            | Self::WidenColumn { schema, .. }
258            | Self::AddNestedField { schema, .. }
259            | Self::RequireColumn { schema, .. } => *schema,
260        }
261    }
262
263    /// The table, whichever change this is.
264    #[must_use]
265    pub const fn table(&self) -> &str {
266        match self {
267            Self::AddColumn { table, .. }
268            | Self::WidenColumn { table, .. }
269            | Self::AddNestedField { table, .. }
270            | Self::RequireColumn { table, .. } => table,
271        }
272    }
273
274    /// The column, whichever change this is.
275    #[must_use]
276    pub const fn column(&self) -> &str {
277        match self {
278            Self::AddColumn { column, .. }
279            | Self::WidenColumn { column, .. }
280            | Self::AddNestedField { column, .. }
281            | Self::RequireColumn { column, .. } => column,
282        }
283    }
284
285    /// The type the column is declared with after the change.
286    #[must_use]
287    pub const fn data_type(&self) -> &DataType {
288        match self {
289            Self::AddColumn { data_type, .. }
290            | Self::WidenColumn { data_type, .. }
291            | Self::AddNestedField { data_type, .. }
292            | Self::RequireColumn { data_type, .. } => data_type,
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn nothing_is_claimed_until_a_dialect_claims_it() {
303        let silent = EvolutionAnswers::default();
304        assert!(!silent.parameterized_string_widening_is_lossless);
305        assert!(!silent.nested_field_addition_is_lossless);
306        assert_eq!(silent, EvolutionAnswers::none());
307    }
308
309    /// **A column is dropped in place unless a dialect says otherwise**, and
310    /// saying so changes that answer alone.
311    #[test]
312    fn a_drop_is_made_in_place_until_a_dialect_says_its_engine_cannot() {
313        assert!(EvolutionAnswers::none().drops_a_column_in_place_before_an_index_column);
314        assert!(
315            EvolutionAnswers::widening_a_string_in_place()
316                .drops_a_column_in_place_before_an_index_column
317        );
318        let duckdb_like = EvolutionAnswers::none().but_no_drop_before_an_index_column();
319        assert!(!duckdb_like.drops_a_column_in_place_before_an_index_column);
320        assert!(!duckdb_like.parameterized_string_widening_is_lossless);
321    }
322
323    #[test]
324    fn an_engine_that_keeps_both_states_both() {
325        let both = EvolutionAnswers::widening_a_string_in_place().and_a_nested_field();
326        assert!(both.parameterized_string_widening_is_lossless);
327        assert!(both.nested_field_addition_is_lossless);
328    }
329
330    #[test]
331    fn a_change_names_its_table_and_column_whichever_change_it_is() {
332        let data_type = DataType::boolean();
333        let change = ColumnEvolution::AddColumn {
334            schema: Some("telemetry_core"),
335            table: "core_clients_daily",
336            column: "is_default_browser",
337            data_type: &data_type,
338        };
339        assert_eq!(change.schema(), Some("telemetry_core"));
340        assert_eq!(change.table(), "core_clients_daily");
341        assert_eq!(change.column(), "is_default_browser");
342    }
343}