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,
46    /// 2026-09-07). A 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/// Renders agnostic [`StoragePlan`] data into platform-native SQL fragments.
95///
96/// All methods have default implementations that produce no SQL. Dialects
97/// override only the methods their platform supports.
98pub trait StorageRenderer {
99    /// Render any standalone partition function DDL (e.g. T-SQL
100    /// `CREATE PARTITION FUNCTION`). Returns a list of statements emitted
101    /// before the `CREATE TABLE`.
102    #[must_use]
103    fn render_partition_function(
104        &self,
105        _entity: EntityRef<'_>,
106        _plan: &StoragePlan,
107    ) -> Vec<String> {
108        Vec::new()
109    }
110
111    /// Render any standalone partition scheme DDL (e.g. T-SQL
112    /// `CREATE PARTITION SCHEME`). Returns statements emitted after the
113    /// partition function and before the `CREATE TABLE`.
114    #[must_use]
115    fn render_partition_scheme(&self, _entity: EntityRef<'_>, _plan: &StoragePlan) -> Vec<String> {
116        Vec::new()
117    }
118
119    /// Render the inline storage clause appended directly to the
120    /// `CREATE TABLE` statement (e.g. `PostgreSQL` `PARTITION BY RANGE (...)`,
121    /// `ClickHouse` `PARTITION BY toYYYYMM(...) SETTINGS ...`).
122    ///
123    /// **On a platform whose
124    /// [`requires_table_engine`](crate::SqlRenderer::requires_table_engine) is
125    /// `true` this clause must contain NEITHER an engine clause nor a sorting
126    /// key.** The `CREATE TABLE` composer emits both, and it
127    /// is the only thing that can: a sorting key has to begin with the inline
128    /// `PRIMARY KEY`, which this function is never handed. A clause that spells
129    /// its own produces two engine clauses in one statement — `Code: 62` on
130    /// `ClickHouse` — or a key the engine rejects at `Code: 36`. Clustering
131    /// intent reaches the sorting key on the table instead, as its clustering
132    /// columns.
133    #[must_use]
134    fn render_table_storage_clause(
135        &self,
136        _entity: EntityRef<'_>,
137        _plan: &StoragePlan,
138    ) -> Option<String> {
139        None
140    }
141
142    /// Render statements emitted after the `CREATE TABLE` (e.g. `PostgreSQL`
143    /// child partition `CREATE TABLE ... PARTITION OF ...`, secondary indexes,
144    /// `CLUSTER BY`).
145    #[must_use]
146    fn render_post_create_storage(
147        &self,
148        _entity: EntityRef<'_>,
149        _plan: &StoragePlan,
150    ) -> Vec<String> {
151        Vec::new()
152    }
153
154    /// Render the maintenance procedure that extends the partitioning window
155    /// (creates the next partition).
156    #[must_use]
157    fn render_partition_extend_procedure(
158        &self,
159        _entity: EntityRef<'_>,
160        _plan: &StoragePlan,
161    ) -> Option<String> {
162        None
163    }
164
165    /// Render the maintenance procedure that retains the partitioning window
166    /// (drops or archives the oldest partition per the retention policy).
167    #[must_use]
168    fn render_partition_retain_procedure(
169        &self,
170        _entity: EntityRef<'_>,
171        _plan: &StoragePlan,
172    ) -> Option<String> {
173        None
174    }
175
176    /// Render this engine's own way of replacing **one slice** of a table:
177    /// the day, month or other value of the time-slice column that a load was
178    /// told to refresh outright.
179    ///
180    /// The default is `None`, which is not a gap — it is the answer for every
181    /// engine that documents no verb for this, and Razeri then writes the
182    /// portable form: a `DELETE` filtered to the slice, followed by the
183    /// insert. Snowflake, Redshift and `DuckDB` take that form because none of
184    /// the three documents a single-slice replace; saying so is better than
185    /// borrowing a neighbour's grammar and calling it theirs.
186    ///
187    /// **Answer with a partition-level verb only when one partition holds
188    /// exactly one slice.** A table partitioned by month and loaded by day has
189    /// thirty slices inside each partition, so truncating or dropping the
190    /// partition that holds the named day would throw away twenty-nine days
191    /// nobody asked about. Check the plan's granularity against the slice
192    /// column's own grain ([`EntityRef::partition_column_type`]) and answer
193    /// `None` when they do not agree — the portable delete is correct at every
194    /// grain. A predicate-based verb, which names the slice rather than the
195    /// partition, has no such limit.
196    ///
197    /// The slice arrives as text naming a bound parameter, not as a value:
198    /// put it where your statement takes the slice, and where your engine
199    /// needs a name rather than an expression, build the name from it the same
200    /// way [`Self::render_partition_extend_procedure`] built it when the
201    /// partition was created. Two spellings of one name is how a load comes to
202    /// clear a partition that does not exist.
203    #[must_use]
204    fn render_slice_replacement(
205        &self,
206        _entity: EntityRef<'_>,
207        _plan: &StoragePlan,
208        _slice: SliceRef<'_>,
209    ) -> Option<SliceReplacement> {
210        None
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    struct DefaultRenderer;
219    impl StorageRenderer for DefaultRenderer {}
220
221    fn entity() -> EntityRef<'static> {
222        EntityRef {
223            schema: Some("core"),
224            name: "events",
225            is_identity_table: false,
226            columns: &[],
227            partition_column_type: None,
228        }
229    }
230
231    #[test]
232    fn defaults_produce_no_sql() {
233        let r = DefaultRenderer;
234        let plan = StoragePlan::none();
235        assert!(r.render_partition_function(entity(), &plan).is_empty());
236        assert!(r.render_partition_scheme(entity(), &plan).is_empty());
237        assert!(r.render_table_storage_clause(entity(), &plan).is_none());
238        assert!(r.render_post_create_storage(entity(), &plan).is_empty());
239        assert!(r.render_partition_extend_procedure(entity(), &plan).is_none());
240        assert!(r.render_partition_retain_procedure(entity(), &plan).is_none());
241        assert!(
242            r.render_slice_replacement(
243                entity(),
244                &plan,
245                SliceRef { column: "day", value: "$p_slice" }
246            )
247            .is_none()
248        );
249    }
250}