razeri-plugin-contract 0.17.1

The dialect contract Razeri plugins are written against — rendering traits, data types, platform identity
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
// Copyright (C) 2026 Gabriel Shea
// Licensed under either of the Apache License, Version 2.0 or the MIT
// license, at your option.

//! Storage rendering trait for partition, compression, and index strategies.
//!
//! Each dialect plugin implements [`StorageRenderer`] to translate the agnostic
//! [`StoragePlan`] into native SQL fragments. All methods have default no-op
//! implementations — dialects that lack a feature simply do not override.
//!
//! Two rules bind every implementation: callers must never branch on dialect
//! name, and implementations must never panic on unsupported features — emit a
//! storage warning event and produce no SQL instead.

use crate::data_type::DataType;
use crate::storage_strategy::StoragePlan;

/// **What a table is to the load that touches it.**
///
/// A storage renderer is asked the same questions about different tables, and
/// some answers depend on which kind it is: emptying one day of a table the
/// load is about to write keeps somewhere for that day, while emptying one day
/// of the landing table the load has just read can drop it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TableRole {
    /// A table a load writes: the target, or a table that holds its rows.
    #[default]
    Written,
    /// The landing table a load reads one day of, staged there before the
    /// load ran. Once the load has read the day, nothing reads it again.
    Landing,
    /// The identity table beside a written table, holding one row per key.
    Identity,
}

/// Lightweight reference to a target entity for storage rendering.
///
/// Carries only what storage rendering needs, so the contract does not depend
/// on Razeri's full design model.
#[derive(Debug, Clone, Copy)]
pub struct EntityRef<'a> {
    /// Optional schema/database namespace.
    pub schema: Option<&'a str>,
    /// Entity name (without schema qualifier).
    pub name: &'a str,
    /// **What this table is to the load**: one it writes, the landing table it
    /// reads a day of, or the identity table beside a written one. Identity
    /// tables generally do not partition, and a landing table's day is done
    /// with once its load has read it.
    pub role: TableRole,
    /// Column names of the target table, in declaration order. Required by
    /// dialects whose index DDL must enumerate columns explicitly — for
    /// example T-SQL `CREATE NONCLUSTERED COLUMNSTORE INDEX ... (col, ...)`,
    /// which fails with `Msg 35336` if the column list is missing. Empty when
    /// the caller does not have column information; renderers that need
    /// columns must skip emission and warn rather than emit invalid DDL.
    pub columns: &'a [&'a str],
    /// The declared type of the plan's partition column, when the caller knows
    /// it.
    ///
    /// **Some platforms spell a partition differently per column type, and
    /// spell it wrong without this.** `GoogleSQL` accepts
    /// `DATE(<timestamp_column>)` and `DATE(<datetime_column>)`, and it accepts
    /// a `DATE` column only *bare* — `PARTITION BY DATE(<date_column>)` is
    /// refused with the grammar quoted back (measured on the service). A
    /// renderer that cannot tell the three apart has to guess,
    /// and one of its guesses is invalid DDL.
    ///
    /// `None` when the caller has no type information; a renderer must then
    /// emit only what is valid for every type it could be, or emit nothing.
    pub partition_column_type: Option<&'a DataType>,
}

/// **The one slice a load was told to replace**, as a statement writes it.
///
/// The value is not known when the SQL is composed — it is a different day on
/// every run — so what travels here is the text that names it: the load
/// procedure's own parameter, already marked so each procedure grammar can
/// decorate it its own way. A renderer puts `value` where its statement takes
/// the slice and never invents a literal of its own.
#[derive(Debug, Clone, Copy)]
pub struct SliceRef<'a> {
    /// The time-slice column the value is compared against.
    pub column: &'a str,
    /// The text that names the value — the bound parameter, in the spelling
    /// composed statements use. Copy it through; do not re-spell it.
    pub value: &'a str,
}

/// **How one engine replaces a single slice of a table.**
///
/// A load asked to refresh one day has to clear that day before it writes it,
/// and engines divide into two answers. Most empty it with a statement of
/// their own that runs first — a partition truncated, a partition dropped, or
/// a row-level delete narrowed to the slice. A few fold the clearing into the
/// write, so there is nothing to run first and the load's own `INSERT` carries
/// a clause instead.
///
/// Answering at all is a claim that the engine documents a verb for this.
/// Where it does not, answer `None` and Razeri writes the portable form: a
/// delete filtered to the slice, then the insert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SliceReplacement {
    /// Statements that empty the named slice, run in order before the load
    /// writes it. Each must be complete and terminated the way this engine's
    /// procedure bodies expect.
    Emptied(Vec<String>),
    /// A clause the load's own `INSERT` carries, which replaces the named
    /// slice with the rows that insert writes — one statement, no separate
    /// clearing. Delta Lake's `REPLACE WHERE` is the worked case.
    WrittenByInsert(String),
}

/// **A second table the retention window governs**, handed to the retain
/// renderer alongside the partitioned one.
///
/// A design can ask a load to set a bad row aside instead of refusing the
/// whole day. Those rows go to a table standing beside the target, holding the
/// row as the load read it and the day it arrived in. That table is not
/// partitioned on any engine — the day it carries is the load's own column
/// rather than the design's time dimension — so dropping an old partition
/// never reaches it, and left alone it grows for as long as the warehouse
/// runs.
///
/// So it travels here instead. Delete from it by [`Self::slice_column`]
/// against the cutoff the partitions are judged by, in the same statement list
/// or procedure body, and an operator who runs retention once keeps both
/// tables inside one window. `None` means there is no such table and there is
/// nothing to add.
#[derive(Debug, Clone, Copy)]
pub struct RetainedCompanion<'a> {
    /// The table itself, addressed the way every other table here is, so it
    /// is qualified and quoted by whatever this renderer already uses.
    pub table: EntityRef<'a>,
    /// The column holding the day each row arrived in — compare the cutoff
    /// against this one, not against the design's time dimension, which this
    /// table does not carry.
    pub slice_column: &'a str,
}

/// **The call that makes a day's partitions**, as
/// [`StorageRenderer::partition_extend_call`] answers it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtendCall {
    /// The procedure, named as this engine calls it: qualified with the
    /// table's schema where [`StorageRenderer::render_partition_extend_procedure`]
    /// created it there, and bare where it did not. Razeri writes this name
    /// into the call as it stands.
    pub procedure: String,
    /// What the call passes after the day, each already spelled: the forward
    /// window, where the engine gives a parameter no default. Empty where every
    /// parameter after the day has a default.
    pub after_the_day: Vec<String>,
    /// Where the call runs.
    pub runs: ExtendRuns,
}

/// **Where the call that makes a day's partitions runs.**
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtendRuns {
    /// Inside the load procedure, as its first statement: the call takes part
    /// in the load's transaction, so an operator calling the load by hand
    /// gets the day made as a scheduler does. A landing table the load reads
    /// rather than writes is still made before the load, by whatever stages
    /// into it.
    InsideTheLoad,
    /// Before the load's transaction opens, from `loads.sql` and the task
    /// list: the call commits the session, so inside the load it would end the
    /// load's transaction half way.
    BeforeTheLoad,
}

/// Renders agnostic [`StoragePlan`] data into platform-native SQL fragments.
///
/// All methods have default implementations that produce no SQL. Dialects
/// override only the methods their platform supports.
pub trait StorageRenderer {
    /// Render any standalone partition function DDL (e.g. T-SQL
    /// `CREATE PARTITION FUNCTION`). Returns a list of statements emitted
    /// before the `CREATE TABLE`.
    #[must_use]
    fn render_partition_function(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Vec<String> {
        Vec::new()
    }

    /// Render any standalone partition scheme DDL (e.g. T-SQL
    /// `CREATE PARTITION SCHEME`). Returns statements emitted after the
    /// partition function and before the `CREATE TABLE`.
    #[must_use]
    fn render_partition_scheme(&self, _entity: EntityRef<'_>, _plan: &StoragePlan) -> Vec<String> {
        Vec::new()
    }

    /// Render the inline storage clause appended directly to the
    /// `CREATE TABLE` statement (e.g. `PostgreSQL` `PARTITION BY RANGE (...)`,
    /// `ClickHouse` `PARTITION BY toYYYYMM(...) SETTINGS ...`).
    ///
    /// It is written after the dialect's own
    /// [`render_table_suffix`](crate::SqlRenderer::render_table_suffix), **and
    /// must not repeat anything that answers.** A dialect whose table suffix
    /// names an engine and a sorting key puts NEITHER here: a sorting key has
    /// to begin with the inline `PRIMARY KEY`, which this function is never
    /// handed and the table suffix is. A clause that spells its own produces
    /// two engine clauses in one statement — `Code: 62` on `ClickHouse` — or a
    /// key the engine rejects at `Code: 36`. Clustering intent reaches such a
    /// sorting key on the table instead, as its clustering columns.
    #[must_use]
    fn render_table_storage_clause(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Option<String> {
        None
    }

    /// Render statements emitted after the `CREATE TABLE` (e.g. `PostgreSQL`
    /// child partition `CREATE TABLE ... PARTITION OF ...`, secondary indexes,
    /// `CLUSTER BY`).
    #[must_use]
    fn render_post_create_storage(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Vec<String> {
        Vec::new()
    }

    /// Render the maintenance procedure that extends the partitioning window
    /// (creates the partitions covering a day and the days after it).
    ///
    /// **The procedure takes the day it should cover.** A warehouse loads the
    /// day a scheduler hands it, and that day is as often behind as ahead: a
    /// backfill runs last Tuesday today. A procedure that reads the clock
    /// instead can only ever cover a window around *now*, so the one day the
    /// caller actually asked about is the one day it may not make. Take the
    /// day as the first parameter, spelled the way the load takes it, and
    /// default it to today where the engine has defaults — the procedure then
    /// still answers the deploy-time call that names no day.
    ///
    /// The forward window is a second parameter where the engine allows one,
    /// so the same call covers the named day and the days a scheduler has not
    /// reached yet.
    #[must_use]
    fn render_partition_extend_procedure(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Option<String> {
        None
    }

    /// Render the maintenance procedure that retains the partitioning window
    /// (drops or archives the oldest partition per the retention policy).
    ///
    /// The day the window is measured back from is a parameter for the same
    /// reason the extend procedure's is, and defaults to today the same way.
    ///
    /// **`also` is a second table the same window governs**, handed over when
    /// there is one. Delete from it against the same cutoff, inside whatever
    /// this method returns, so that one call keeps both tables. See
    /// [`RetainedCompanion`] for why it cannot be partitioned along with the
    /// first.
    #[must_use]
    fn render_partition_retain_procedure(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
        _also: Option<RetainedCompanion<'_>>,
    ) -> Option<String> {
        None
    }

    /// **The call that makes a day's partitions before a load writes it**, and
    /// where that call runs — or `None` where this engine needs no such call.
    ///
    /// Engines divide on what happens to a row whose day falls outside every
    /// partition that has been declared. Some refuse it: `PostgreSQL`
    /// declarative partitioning answers *"no partition of relation … found for
    /// row"*. Others keep somewhere for it — a range ending in a catch-all, a
    /// partition function with no boundaries yet — and the row lands, but in
    /// the one partition every day shares, so a table stored by day is one
    /// partition until somebody opens its window. Either way the day the load
    /// was handed is the day to make, and the load is the only place that knows
    /// it.
    ///
    /// Answer with the procedure [`Self::render_partition_extend_procedure`]
    /// created, and only where it is rendered at all, and say where its call
    /// runs ([`ExtendRuns`]): inside the load, as its first statement, where
    /// the call takes part in the load's transaction; or before the load's
    /// transaction opens, where the call commits the session — `MySQL`'s
    /// `REORGANIZE PARTITION` does — and inside the load would end the load's
    /// transaction half way. Razeri writes the call with the same day the load
    /// is given.
    #[must_use]
    fn partition_extend_call(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
    ) -> Option<ExtendCall> {
        None
    }

    /// Render this engine's own way of replacing **one slice** of a table:
    /// the day, month or other value of the time-slice column that a load was
    /// told to refresh outright.
    ///
    /// The default is `None`, which is not a gap — it is the answer for every
    /// engine that documents no verb for this, and Razeri then writes the
    /// portable form: a `DELETE` filtered to the slice, followed by the
    /// insert. Snowflake, Redshift and `DuckDB` take that form because none of
    /// the three documents a single-slice replace; saying so is better than
    /// borrowing a neighbour's grammar and calling it theirs.
    ///
    /// **Answer with a partition-level verb only when one partition holds
    /// exactly one slice.** A table partitioned by month and loaded by day has
    /// thirty slices inside each partition, so truncating or dropping the
    /// partition that holds the named day would throw away twenty-nine days
    /// nobody asked about. Check the plan's granularity against the slice
    /// column's own grain ([`EntityRef::partition_column_type`]) and answer
    /// `None` when they do not agree — the portable delete is correct at every
    /// grain. A predicate-based verb, which names the slice rather than the
    /// partition, has no such limit.
    ///
    /// The slice arrives as text naming a bound parameter, not as a value:
    /// put it where your statement takes the slice, and where your engine
    /// needs a name rather than an expression, build the name from it the same
    /// way [`Self::render_partition_extend_procedure`] built it when the
    /// partition was created. Two spellings of one name is how a load comes to
    /// clear a partition that does not exist.
    #[must_use]
    fn render_slice_replacement(
        &self,
        _entity: EntityRef<'_>,
        _plan: &StoragePlan,
        _slice: SliceRef<'_>,
    ) -> Option<SliceReplacement> {
        None
    }

    /// **Whether the answer you just gave only runs inside a procedure**, so a
    /// caller knows where it may put it.
    ///
    /// The answers divide in two. Most are complete statements — an
    /// `ALTER TABLE … DROP PARTITION`, a `SET` and a `PREPARE` — and anything
    /// that runs statements runs them as they stand. Some are written in the
    /// procedural language the engine's procedure bodies are written in, and
    /// those are right inside the load Razeri generates and a syntax error
    /// anywhere else. Answer `true` and Razeri puts your text where it belongs
    /// and offers a plain delete to whoever wanted something to paste;
    /// answer `false` and your own text is what they are offered.
    ///
    /// Leave it alone and Razeri reads the shape of your answer instead: a
    /// statement that opens with `IF` and carries `END IF` is taken for a
    /// procedure body, and everything else for a statement. That reading is
    /// right about the engines it was written from and it cannot be right
    /// about every engine — a block spelled `IF EXISTS (…) BEGIN … END` runs
    /// on its own on one engine and inside a routine on the next, and no
    /// amount of looking at the text says which. So the engine says.
    ///
    /// Parameters:
    /// - `replacement`: the answer [`Self::render_slice_replacement`] gave for
    ///   this table. A clause the write carries goes wherever the write goes,
    ///   so `WrittenByInsert` is never a procedure body.
    #[must_use]
    fn slice_replacement_is_procedure_body(&self, replacement: &SliceReplacement) -> bool {
        let SliceReplacement::Emptied(statements) = replacement else {
            return false;
        };
        statements.iter().any(|statement| {
            let opens_a_conditional = statement
                .split_whitespace()
                .next()
                .is_some_and(|word| word.eq_ignore_ascii_case("IF"));
            opens_a_conditional && statement.to_ascii_uppercase().contains("END IF")
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct DefaultRenderer;
    impl StorageRenderer for DefaultRenderer {}

    fn entity() -> EntityRef<'static> {
        EntityRef {
            schema: Some("core"),
            name: "events",
            role: TableRole::Written,
            columns: &[],
            partition_column_type: None,
        }
    }

    #[test]
    fn defaults_produce_no_sql() {
        let r = DefaultRenderer;
        let plan = StoragePlan::none();
        assert!(r.render_partition_function(entity(), &plan).is_empty());
        assert!(r.render_partition_scheme(entity(), &plan).is_empty());
        assert!(r.render_table_storage_clause(entity(), &plan).is_none());
        assert!(r.render_post_create_storage(entity(), &plan).is_empty());
        assert!(r.render_partition_extend_procedure(entity(), &plan).is_none());
        assert!(r.render_partition_retain_procedure(entity(), &plan, None).is_none());
        assert!(r.partition_extend_call(entity(), &plan).is_none());
        assert!(
            r.render_slice_replacement(
                entity(),
                &plan,
                SliceRef { column: "day", value: "$p_slice" }
            )
            .is_none()
        );
    }

    /// The default reads the shape: a conditional is a procedure body, a
    /// statement is not, and a clause the write carries never is.
    #[test]
    fn the_default_reads_the_shape_of_the_answer() {
        let r = DefaultRenderer;
        assert!(r.slice_replacement_is_procedure_body(&SliceReplacement::Emptied(vec![
            "IF to_regclass('core.events_20260917') IS NOT NULL THEN\n    TRUNCATE TABLE \
             core.events_20260917;\nEND IF;"
                .to_owned()
        ])));
        assert!(!r.slice_replacement_is_procedure_body(&SliceReplacement::Emptied(vec![
            "ALTER TABLE core.events DROP PARTITION 20260917;".to_owned()
        ])));
        assert!(!r.slice_replacement_is_procedure_body(&SliceReplacement::WrittenByInsert(
            "REPLACE WHERE day = '2026-09-17'".to_owned()
        )));
    }

    /// **An engine whose conditional runs on its own says so, and the shape is
    /// not consulted.** The text below is the one the default reads wrongly:
    /// it opens with `IF` and closes with `END IF`, and on an engine that
    /// executes a compound statement from a plain client it needs no procedure
    /// around it.
    #[test]
    fn an_engine_that_states_it_runs_alone_is_believed() {
        struct RunsAlone;
        impl StorageRenderer for RunsAlone {
            fn slice_replacement_is_procedure_body(&self, _replacement: &SliceReplacement) -> bool {
                false
            }
        }
        let conditional = SliceReplacement::Emptied(vec![
            "IF EXISTS (SELECT 1 FROM core.events WHERE day = '2026-09-17') THEN\n    DELETE FROM \
             core.events WHERE day = '2026-09-17';\nEND IF;"
                .to_owned(),
        ]);
        assert!(DefaultRenderer.slice_replacement_is_procedure_body(&conditional));
        assert!(!RunsAlone.slice_replacement_is_procedure_body(&conditional));
    }
}