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/// **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 /// **On a platform whose
151 /// [`requires_table_engine`](crate::SqlRenderer::requires_table_engine) is
152 /// `true` this clause must contain NEITHER an engine clause nor a sorting
153 /// key.** The `CREATE TABLE` composer emits both, and it
154 /// is the only thing that can: a sorting key has to begin with the inline
155 /// `PRIMARY KEY`, which this function is never handed. A clause that spells
156 /// its own produces two engine clauses in one statement — `Code: 62` on
157 /// `ClickHouse` — or a key the engine rejects at `Code: 36`. Clustering
158 /// intent reaches the sorting key on the table instead, as its clustering
159 /// columns.
160 #[must_use]
161 fn render_table_storage_clause(
162 &self,
163 _entity: EntityRef<'_>,
164 _plan: &StoragePlan,
165 ) -> Option<String> {
166 None
167 }
168
169 /// Render statements emitted after the `CREATE TABLE` (e.g. `PostgreSQL`
170 /// child partition `CREATE TABLE ... PARTITION OF ...`, secondary indexes,
171 /// `CLUSTER BY`).
172 #[must_use]
173 fn render_post_create_storage(
174 &self,
175 _entity: EntityRef<'_>,
176 _plan: &StoragePlan,
177 ) -> Vec<String> {
178 Vec::new()
179 }
180
181 /// Render the maintenance procedure that extends the partitioning window
182 /// (creates the partitions covering a day and the days after it).
183 ///
184 /// **The procedure takes the day it should cover.** A warehouse loads the
185 /// day a scheduler hands it, and that day is as often behind as ahead: a
186 /// backfill runs last Tuesday today. A procedure that reads the clock
187 /// instead can only ever cover a window around *now*, so the one day the
188 /// caller actually asked about is the one day it may not make. Take the
189 /// day as the first parameter, spelled the way the load takes it, and
190 /// default it to today where the engine has defaults — the procedure then
191 /// still answers the deploy-time call that names no day.
192 ///
193 /// The forward window is a second parameter where the engine allows one,
194 /// so the same call covers the named day and the days a scheduler has not
195 /// reached yet.
196 #[must_use]
197 fn render_partition_extend_procedure(
198 &self,
199 _entity: EntityRef<'_>,
200 _plan: &StoragePlan,
201 ) -> Option<String> {
202 None
203 }
204
205 /// Render the maintenance procedure that retains the partitioning window
206 /// (drops or archives the oldest partition per the retention policy).
207 ///
208 /// The day the window is measured back from is a parameter for the same
209 /// reason the extend procedure's is, and defaults to today the same way.
210 ///
211 /// **`also` is a second table the same window governs**, handed over when
212 /// there is one. Delete from it against the same cutoff, inside whatever
213 /// this method returns, so that one call keeps both tables. See
214 /// [`RetainedCompanion`] for why it cannot be partitioned along with the
215 /// first.
216 #[must_use]
217 fn render_partition_retain_procedure(
218 &self,
219 _entity: EntityRef<'_>,
220 _plan: &StoragePlan,
221 _also: Option<RetainedCompanion<'_>>,
222 ) -> Option<String> {
223 None
224 }
225
226 /// **The extend procedure a load must call before it writes a day**, or
227 /// `None` where this engine needs no such call.
228 ///
229 /// Engines divide on what happens to a row whose day falls outside every
230 /// partition that has been declared. Most keep somewhere for it to go — a
231 /// range ending in a catch-all, a partition function with no boundaries
232 /// yet, a partition the engine derives from the value itself — so the row
233 /// lands whatever the maintenance window has reached, and extending is a
234 /// tidiness the load need not wait for. `PostgreSQL` declarative
235 /// partitioning keeps nowhere: a parent with no matching child answers
236 /// *"no partition of relation … found for row"* and the load stops.
237 ///
238 /// Answer with the procedure's name — unqualified, the way
239 /// [`Self::render_partition_extend_procedure`] names the object it creates
240 /// — and only where that procedure is rendered at all. Razeri then writes
241 /// the call ahead of the load, with the same day the load is given.
242 #[must_use]
243 fn partition_extend_call_before_write(
244 &self,
245 _entity: EntityRef<'_>,
246 _plan: &StoragePlan,
247 ) -> Option<String> {
248 None
249 }
250
251 /// Render this engine's own way of replacing **one slice** of a table:
252 /// the day, month or other value of the time-slice column that a load was
253 /// told to refresh outright.
254 ///
255 /// The default is `None`, which is not a gap — it is the answer for every
256 /// engine that documents no verb for this, and Razeri then writes the
257 /// portable form: a `DELETE` filtered to the slice, followed by the
258 /// insert. Snowflake, Redshift and `DuckDB` take that form because none of
259 /// the three documents a single-slice replace; saying so is better than
260 /// borrowing a neighbour's grammar and calling it theirs.
261 ///
262 /// **Answer with a partition-level verb only when one partition holds
263 /// exactly one slice.** A table partitioned by month and loaded by day has
264 /// thirty slices inside each partition, so truncating or dropping the
265 /// partition that holds the named day would throw away twenty-nine days
266 /// nobody asked about. Check the plan's granularity against the slice
267 /// column's own grain ([`EntityRef::partition_column_type`]) and answer
268 /// `None` when they do not agree — the portable delete is correct at every
269 /// grain. A predicate-based verb, which names the slice rather than the
270 /// partition, has no such limit.
271 ///
272 /// The slice arrives as text naming a bound parameter, not as a value:
273 /// put it where your statement takes the slice, and where your engine
274 /// needs a name rather than an expression, build the name from it the same
275 /// way [`Self::render_partition_extend_procedure`] built it when the
276 /// partition was created. Two spellings of one name is how a load comes to
277 /// clear a partition that does not exist.
278 #[must_use]
279 fn render_slice_replacement(
280 &self,
281 _entity: EntityRef<'_>,
282 _plan: &StoragePlan,
283 _slice: SliceRef<'_>,
284 ) -> Option<SliceReplacement> {
285 None
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 struct DefaultRenderer;
294 impl StorageRenderer for DefaultRenderer {}
295
296 fn entity() -> EntityRef<'static> {
297 EntityRef {
298 schema: Some("core"),
299 name: "events",
300 is_identity_table: false,
301 columns: &[],
302 partition_column_type: None,
303 }
304 }
305
306 #[test]
307 fn defaults_produce_no_sql() {
308 let r = DefaultRenderer;
309 let plan = StoragePlan::none();
310 assert!(r.render_partition_function(entity(), &plan).is_empty());
311 assert!(r.render_partition_scheme(entity(), &plan).is_empty());
312 assert!(r.render_table_storage_clause(entity(), &plan).is_none());
313 assert!(r.render_post_create_storage(entity(), &plan).is_empty());
314 assert!(r.render_partition_extend_procedure(entity(), &plan).is_none());
315 assert!(r.render_partition_retain_procedure(entity(), &plan, None).is_none());
316 assert!(r.partition_extend_call_before_write(entity(), &plan).is_none());
317 assert!(
318 r.render_slice_replacement(
319 entity(),
320 &plan,
321 SliceRef { column: "day", value: "$p_slice" }
322 )
323 .is_none()
324 );
325 }
326}