Skip to main content

razeri_plugin_contract/
storage_renderer.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//! Storage rendering trait for partition, compression, and index strategies.
6//!
7//! Each dialect plugin implements [`StorageRenderer`] to translate the agnostic
8//! [`StoragePlan`] into native SQL fragments. All methods have default no-op
9//! implementations — dialects that lack a feature simply do not override.
10//!
11//! Two rules bind every implementation: callers must never branch on dialect
12//! name, and implementations must never panic on unsupported features — emit a
13//! storage warning event and produce no SQL instead.
14
15use crate::data_type::DataType;
16use crate::storage_strategy::StoragePlan;
17
18/// **What a table is to the load that touches it.**
19///
20/// A storage renderer is asked the same questions about different tables, and
21/// some answers depend on which kind it is: emptying one day of a table the
22/// load is about to write keeps somewhere for that day, while emptying one day
23/// of the landing table the load has just read can drop it.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum TableRole {
26    /// A table a load writes: the target, or a table that holds its rows.
27    #[default]
28    Written,
29    /// The landing table a load reads one day of, staged there before the
30    /// load ran. Once the load has read the day, nothing reads it again.
31    Landing,
32    /// The identity table beside a written table, holding one row per key.
33    Identity,
34}
35
36/// Lightweight reference to a target entity for storage rendering.
37///
38/// Carries only what storage rendering needs, so the contract does not depend
39/// on Razeri's full design model.
40#[derive(Debug, Clone, Copy)]
41pub struct EntityRef<'a> {
42    /// Optional schema/database namespace.
43    pub schema: Option<&'a str>,
44    /// Entity name (without schema qualifier).
45    pub name: &'a str,
46    /// **What this table is to the load**: one it writes, the landing table it
47    /// reads a day of, or the identity table beside a written one. Identity
48    /// tables generally do not partition, and a landing table's day is done
49    /// with once its load has read it.
50    pub role: TableRole,
51    /// Column names of the target table, in declaration order. Required by
52    /// dialects whose index DDL must enumerate columns explicitly — for
53    /// example T-SQL `CREATE NONCLUSTERED COLUMNSTORE INDEX ... (col, ...)`,
54    /// which fails with `Msg 35336` if the column list is missing. Empty when
55    /// the caller does not have column information; renderers that need
56    /// columns must skip emission and warn rather than emit invalid DDL.
57    pub columns: &'a [&'a str],
58    /// The declared type of the plan's partition column, when the caller knows
59    /// it.
60    ///
61    /// **Some platforms spell a partition differently per column type, and
62    /// spell it wrong without this.** `GoogleSQL` accepts
63    /// `DATE(<timestamp_column>)` and `DATE(<datetime_column>)`, and it accepts
64    /// a `DATE` column only *bare* — `PARTITION BY DATE(<date_column>)` is
65    /// refused with the grammar quoted back (measured on the service). A
66    /// renderer that cannot tell the three apart has to guess,
67    /// and one of its guesses is invalid DDL.
68    ///
69    /// `None` when the caller has no type information; a renderer must then
70    /// emit only what is valid for every type it could be, or emit nothing.
71    pub partition_column_type: Option<&'a DataType>,
72}
73
74/// **The one slice a load was told to replace**, as a statement writes it.
75///
76/// The value is not known when the SQL is composed — it is a different day on
77/// every run — so what travels here is the text that names it: the load
78/// procedure's own parameter, already marked so each procedure grammar can
79/// decorate it its own way. A renderer puts `value` where its statement takes
80/// the slice and never invents a literal of its own.
81#[derive(Debug, Clone, Copy)]
82pub struct SliceRef<'a> {
83    /// The time-slice column the value is compared against.
84    pub column: &'a str,
85    /// The text that names the value — the bound parameter, in the spelling
86    /// composed statements use. Copy it through; do not re-spell it.
87    pub value: &'a str,
88}
89
90/// **How one engine replaces a single slice of a table.**
91///
92/// A load asked to refresh one day has to clear that day before it writes it,
93/// and engines divide into two answers. Most empty it with a statement of
94/// their own that runs first — a partition truncated, a partition dropped, or
95/// a row-level delete narrowed to the slice. A few fold the clearing into the
96/// write, so there is nothing to run first and the load's own `INSERT` carries
97/// a clause instead.
98///
99/// Answering at all is a claim that the engine documents a verb for this.
100/// Where it does not, answer `None` and Razeri writes the portable form: a
101/// delete filtered to the slice, then the insert.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum SliceReplacement {
104    /// Statements that empty the named slice, run in order before the load
105    /// writes it. Each must be complete and terminated the way this engine's
106    /// procedure bodies expect.
107    Emptied(Vec<String>),
108    /// A clause the load's own `INSERT` carries, which replaces the named
109    /// slice with the rows that insert writes — one statement, no separate
110    /// clearing. Delta Lake's `REPLACE WHERE` is the worked case.
111    WrittenByInsert(String),
112}
113
114/// **A second table the retention window governs**, handed to the retain
115/// renderer alongside the partitioned one.
116///
117/// A design can ask a load to set a bad row aside instead of refusing the
118/// whole day. Those rows go to a table standing beside the target, holding the
119/// row as the load read it and the day it arrived in. That table is not
120/// partitioned on any engine — the day it carries is the load's own column
121/// rather than the design's time dimension — so dropping an old partition
122/// never reaches it, and left alone it grows for as long as the warehouse
123/// runs.
124///
125/// So it travels here instead. Delete from it by [`Self::slice_column`]
126/// against the cutoff the partitions are judged by, in the same statement list
127/// or procedure body, and an operator who runs retention once keeps both
128/// tables inside one window. `None` means there is no such table and there is
129/// nothing to add.
130#[derive(Debug, Clone, Copy)]
131pub struct RetainedCompanion<'a> {
132    /// The table itself, addressed the way every other table here is, so it
133    /// is qualified and quoted by whatever this renderer already uses.
134    pub table: EntityRef<'a>,
135    /// The column holding the day each row arrived in — compare the cutoff
136    /// against this one, not against the design's time dimension, which this
137    /// table does not carry.
138    pub slice_column: &'a str,
139}
140
141/// **The call that makes a day's partitions**, as
142/// [`StorageRenderer::partition_extend_call`] answers it.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct ExtendCall {
145    /// The procedure, named as this engine calls it: qualified with the
146    /// table's schema where [`StorageRenderer::render_partition_extend_procedure`]
147    /// created it there, and bare where it did not. Razeri writes this name
148    /// into the call as it stands.
149    pub procedure: String,
150    /// What the call passes after the day, each already spelled: the forward
151    /// window, where the engine gives a parameter no default. Empty where every
152    /// parameter after the day has a default.
153    pub after_the_day: Vec<String>,
154    /// Where the call runs.
155    pub runs: ExtendRuns,
156}
157
158/// **Where the call that makes a day's partitions runs.**
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ExtendRuns {
161    /// Inside the load procedure, as its first statement: the call takes part
162    /// in the load's transaction, so an operator calling the load by hand
163    /// gets the day made as a scheduler does. A landing table the load reads
164    /// rather than writes is still made before the load, by whatever stages
165    /// into it.
166    InsideTheLoad,
167    /// Before the load's transaction opens, from `loads.sql` and the task
168    /// list: the call commits the session, so inside the load it would end the
169    /// load's transaction half way.
170    BeforeTheLoad,
171}
172
173/// Renders agnostic [`StoragePlan`] data into platform-native SQL fragments.
174///
175/// All methods have default implementations that produce no SQL. Dialects
176/// override only the methods their platform supports.
177pub trait StorageRenderer {
178    /// Render any standalone partition function DDL (e.g. T-SQL
179    /// `CREATE PARTITION FUNCTION`). Returns a list of statements emitted
180    /// before the `CREATE TABLE`.
181    #[must_use]
182    fn render_partition_function(
183        &self,
184        _entity: EntityRef<'_>,
185        _plan: &StoragePlan,
186    ) -> Vec<String> {
187        Vec::new()
188    }
189
190    /// Render any standalone partition scheme DDL (e.g. T-SQL
191    /// `CREATE PARTITION SCHEME`). Returns statements emitted after the
192    /// partition function and before the `CREATE TABLE`.
193    #[must_use]
194    fn render_partition_scheme(&self, _entity: EntityRef<'_>, _plan: &StoragePlan) -> Vec<String> {
195        Vec::new()
196    }
197
198    /// Render the inline storage clause appended directly to the
199    /// `CREATE TABLE` statement (e.g. `PostgreSQL` `PARTITION BY RANGE (...)`,
200    /// `ClickHouse` `PARTITION BY toYYYYMM(...) SETTINGS ...`).
201    ///
202    /// It is written after the dialect's own
203    /// [`render_table_suffix`](crate::SqlRenderer::render_table_suffix), **and
204    /// must not repeat anything that answers.** A dialect whose table suffix
205    /// names an engine and a sorting key puts NEITHER here: a sorting key has
206    /// to begin with the inline `PRIMARY KEY`, which this function is never
207    /// handed and the table suffix is. A clause that spells its own produces
208    /// two engine clauses in one statement — `Code: 62` on `ClickHouse` — or a
209    /// key the engine rejects at `Code: 36`. Clustering intent reaches such a
210    /// sorting key on the table instead, as its clustering columns.
211    #[must_use]
212    fn render_table_storage_clause(
213        &self,
214        _entity: EntityRef<'_>,
215        _plan: &StoragePlan,
216    ) -> Option<String> {
217        None
218    }
219
220    /// Render statements emitted after the `CREATE TABLE` (e.g. `PostgreSQL`
221    /// child partition `CREATE TABLE ... PARTITION OF ...`, secondary indexes,
222    /// `CLUSTER BY`).
223    #[must_use]
224    fn render_post_create_storage(
225        &self,
226        _entity: EntityRef<'_>,
227        _plan: &StoragePlan,
228    ) -> Vec<String> {
229        Vec::new()
230    }
231
232    /// Render the maintenance procedure that extends the partitioning window
233    /// (creates the partitions covering a day and the days after it).
234    ///
235    /// **The procedure takes the day it should cover.** A warehouse loads the
236    /// day a scheduler hands it, and that day is as often behind as ahead: a
237    /// backfill runs last Tuesday today. A procedure that reads the clock
238    /// instead can only ever cover a window around *now*, so the one day the
239    /// caller actually asked about is the one day it may not make. Take the
240    /// day as the first parameter, spelled the way the load takes it, and
241    /// default it to today where the engine has defaults — the procedure then
242    /// still answers the deploy-time call that names no day.
243    ///
244    /// The forward window is a second parameter where the engine allows one,
245    /// so the same call covers the named day and the days a scheduler has not
246    /// reached yet.
247    #[must_use]
248    fn render_partition_extend_procedure(
249        &self,
250        _entity: EntityRef<'_>,
251        _plan: &StoragePlan,
252    ) -> Option<String> {
253        None
254    }
255
256    /// Render the maintenance procedure that retains the partitioning window
257    /// (drops or archives the oldest partition per the retention policy).
258    ///
259    /// The day the window is measured back from is a parameter for the same
260    /// reason the extend procedure's is, and defaults to today the same way.
261    ///
262    /// **`also` is a second table the same window governs**, handed over when
263    /// there is one. Delete from it against the same cutoff, inside whatever
264    /// this method returns, so that one call keeps both tables. See
265    /// [`RetainedCompanion`] for why it cannot be partitioned along with the
266    /// first.
267    #[must_use]
268    fn render_partition_retain_procedure(
269        &self,
270        _entity: EntityRef<'_>,
271        _plan: &StoragePlan,
272        _also: Option<RetainedCompanion<'_>>,
273    ) -> Option<String> {
274        None
275    }
276
277    /// **The call that makes a day's partitions before a load writes it**, and
278    /// where that call runs — or `None` where this engine needs no such call.
279    ///
280    /// Engines divide on what happens to a row whose day falls outside every
281    /// partition that has been declared. Some refuse it: `PostgreSQL`
282    /// declarative partitioning answers *"no partition of relation … found for
283    /// row"*. Others keep somewhere for it — a range ending in a catch-all, a
284    /// partition function with no boundaries yet — and the row lands, but in
285    /// the one partition every day shares, so a table stored by day is one
286    /// partition until somebody opens its window. Either way the day the load
287    /// was handed is the day to make, and the load is the only place that knows
288    /// it.
289    ///
290    /// Answer with the procedure [`Self::render_partition_extend_procedure`]
291    /// created, and only where it is rendered at all, and say where its call
292    /// runs ([`ExtendRuns`]): inside the load, as its first statement, where
293    /// the call takes part in the load's transaction; or before the load's
294    /// transaction opens, where the call commits the session — `MySQL`'s
295    /// `REORGANIZE PARTITION` does — and inside the load would end the load's
296    /// transaction half way. Razeri writes the call with the same day the load
297    /// is given.
298    #[must_use]
299    fn partition_extend_call(
300        &self,
301        _entity: EntityRef<'_>,
302        _plan: &StoragePlan,
303    ) -> Option<ExtendCall> {
304        None
305    }
306
307    /// Render this engine's own way of replacing **one slice** of a table:
308    /// the day, month or other value of the time-slice column that a load was
309    /// told to refresh outright.
310    ///
311    /// The default is `None`, which is not a gap — it is the answer for every
312    /// engine that documents no verb for this, and Razeri then writes the
313    /// portable form: a `DELETE` filtered to the slice, followed by the
314    /// insert. Snowflake, Redshift and `DuckDB` take that form because none of
315    /// the three documents a single-slice replace; saying so is better than
316    /// borrowing a neighbour's grammar and calling it theirs.
317    ///
318    /// **Answer with a partition-level verb only when one partition holds
319    /// exactly one slice.** A table partitioned by month and loaded by day has
320    /// thirty slices inside each partition, so truncating or dropping the
321    /// partition that holds the named day would throw away twenty-nine days
322    /// nobody asked about. Check the plan's granularity against the slice
323    /// column's own grain ([`EntityRef::partition_column_type`]) and answer
324    /// `None` when they do not agree — the portable delete is correct at every
325    /// grain. A predicate-based verb, which names the slice rather than the
326    /// partition, has no such limit.
327    ///
328    /// The slice arrives as text naming a bound parameter, not as a value:
329    /// put it where your statement takes the slice, and where your engine
330    /// needs a name rather than an expression, build the name from it the same
331    /// way [`Self::render_partition_extend_procedure`] built it when the
332    /// partition was created. Two spellings of one name is how a load comes to
333    /// clear a partition that does not exist.
334    #[must_use]
335    fn render_slice_replacement(
336        &self,
337        _entity: EntityRef<'_>,
338        _plan: &StoragePlan,
339        _slice: SliceRef<'_>,
340    ) -> Option<SliceReplacement> {
341        None
342    }
343
344    /// **Whether the answer you just gave only runs inside a procedure**, so a
345    /// caller knows where it may put it.
346    ///
347    /// The answers divide in two. Most are complete statements — an
348    /// `ALTER TABLE … DROP PARTITION`, a `SET` and a `PREPARE` — and anything
349    /// that runs statements runs them as they stand. Some are written in the
350    /// procedural language the engine's procedure bodies are written in, and
351    /// those are right inside the load Razeri generates and a syntax error
352    /// anywhere else. Answer `true` and Razeri puts your text where it belongs
353    /// and offers a plain delete to whoever wanted something to paste;
354    /// answer `false` and your own text is what they are offered.
355    ///
356    /// Leave it alone and Razeri reads the shape of your answer instead: a
357    /// statement that opens with `IF` and carries `END IF` is taken for a
358    /// procedure body, and everything else for a statement. That reading is
359    /// right about the engines it was written from and it cannot be right
360    /// about every engine — a block spelled `IF EXISTS (…) BEGIN … END` runs
361    /// on its own on one engine and inside a routine on the next, and no
362    /// amount of looking at the text says which. So the engine says.
363    ///
364    /// Parameters:
365    /// - `replacement`: the answer [`Self::render_slice_replacement`] gave for
366    ///   this table. A clause the write carries goes wherever the write goes,
367    ///   so `WrittenByInsert` is never a procedure body.
368    #[must_use]
369    fn slice_replacement_is_procedure_body(&self, replacement: &SliceReplacement) -> bool {
370        let SliceReplacement::Emptied(statements) = replacement else {
371            return false;
372        };
373        statements.iter().any(|statement| {
374            let opens_a_conditional = statement
375                .split_whitespace()
376                .next()
377                .is_some_and(|word| word.eq_ignore_ascii_case("IF"));
378            opens_a_conditional && statement.to_ascii_uppercase().contains("END IF")
379        })
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    struct DefaultRenderer;
388    impl StorageRenderer for DefaultRenderer {}
389
390    fn entity() -> EntityRef<'static> {
391        EntityRef {
392            schema: Some("core"),
393            name: "events",
394            role: TableRole::Written,
395            columns: &[],
396            partition_column_type: None,
397        }
398    }
399
400    #[test]
401    fn defaults_produce_no_sql() {
402        let r = DefaultRenderer;
403        let plan = StoragePlan::none();
404        assert!(r.render_partition_function(entity(), &plan).is_empty());
405        assert!(r.render_partition_scheme(entity(), &plan).is_empty());
406        assert!(r.render_table_storage_clause(entity(), &plan).is_none());
407        assert!(r.render_post_create_storage(entity(), &plan).is_empty());
408        assert!(r.render_partition_extend_procedure(entity(), &plan).is_none());
409        assert!(r.render_partition_retain_procedure(entity(), &plan, None).is_none());
410        assert!(r.partition_extend_call(entity(), &plan).is_none());
411        assert!(
412            r.render_slice_replacement(
413                entity(),
414                &plan,
415                SliceRef { column: "day", value: "$p_slice" }
416            )
417            .is_none()
418        );
419    }
420
421    /// The default reads the shape: a conditional is a procedure body, a
422    /// statement is not, and a clause the write carries never is.
423    #[test]
424    fn the_default_reads_the_shape_of_the_answer() {
425        let r = DefaultRenderer;
426        assert!(r.slice_replacement_is_procedure_body(&SliceReplacement::Emptied(vec![
427            "IF to_regclass('core.events_20260917') IS NOT NULL THEN\n    TRUNCATE TABLE \
428             core.events_20260917;\nEND IF;"
429                .to_owned()
430        ])));
431        assert!(!r.slice_replacement_is_procedure_body(&SliceReplacement::Emptied(vec![
432            "ALTER TABLE core.events DROP PARTITION 20260917;".to_owned()
433        ])));
434        assert!(!r.slice_replacement_is_procedure_body(&SliceReplacement::WrittenByInsert(
435            "REPLACE WHERE day = '2026-09-17'".to_owned()
436        )));
437    }
438
439    /// **An engine whose conditional runs on its own says so, and the shape is
440    /// not consulted.** The text below is the one the default reads wrongly:
441    /// it opens with `IF` and closes with `END IF`, and on an engine that
442    /// executes a compound statement from a plain client it needs no procedure
443    /// around it.
444    #[test]
445    fn an_engine_that_states_it_runs_alone_is_believed() {
446        struct RunsAlone;
447        impl StorageRenderer for RunsAlone {
448            fn slice_replacement_is_procedure_body(&self, _replacement: &SliceReplacement) -> bool {
449                false
450            }
451        }
452        let conditional = SliceReplacement::Emptied(vec![
453            "IF EXISTS (SELECT 1 FROM core.events WHERE day = '2026-09-17') THEN\n    DELETE FROM \
454             core.events WHERE day = '2026-09-17';\nEND IF;"
455                .to_owned(),
456        ]);
457        assert!(DefaultRenderer.slice_replacement_is_procedure_body(&conditional));
458        assert!(!RunsAlone.slice_replacement_is_procedure_body(&conditional));
459    }
460}