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