Skip to main content

razeri_plugin_contract/
replication.rs

1// Copyright (C) 2026 Gabriel Shea <gshea@ideapivot.com> - All Rights Reserved
2
3//! Replication rendering traits with ANSI defaults.
4//!
5//! [`ReplicationRenderer`] extends [`SqlRenderer`] with methods for
6//! SID (surrogate ID) function generation, data extraction, data loading, and
7//! checksum validation. Each dialect crate overrides these methods to produce
8//! platform-native SQL.
9//!
10//! ## Format names
11//!
12//! Methods that accept a `format` parameter use well-known string identifiers:
13//!
14//! | Name | Description |
15//! |------|-------------|
16//! | `"csv"` | Comma-separated values |
17//! | `"parquet"` | Apache Parquet columnar format |
18//! | `"json_lines"` | JSON Lines (one object per line) |
19//! | `"native"` | Platform-native binary format |
20
21use crate::SqlRenderer;
22
23// ════════════════════════════════════════════════════════════════════════
24// SID Prerequisites — the statement boundary
25// ════════════════════════════════════════════════════════════════════════
26
27/// **The line a dialect writes between two independently-executable statements
28/// inside [`ReplicationRenderer::sid_prerequisites`].**
29///
30/// A prerequisite script is mostly prose — the `MariaDB` C source and the SQL
31/// Server C# are emitted as comment blocks for an operator to compile — but two
32/// arms emit real DDL a machine must run one statement at a time: Snowflake's
33/// JavaScript UDF plus its SQL wrapper, and Databricks' Python UDF. Neither can
34/// be recovered by splitting on `;`, because **both bodies contain
35/// semicolons**: a JavaScript function body is nothing but semicolons.
36///
37/// So the boundary is declared rather than inferred. The delimiter is a SQL line
38/// comment, so a script carrying it is still one paste-able file for a human
39/// operator, and [`ReplicationRenderer::sid_prerequisite_statements`] is the one
40/// splitter — a consumer that re-derived the boundary would be a second
41/// definition of it, and a second definition is how the two drift apart.
42///
43/// **The inference-based sibling exists and is acknowledged here**:
44/// `split_snowflake_sql` in
45/// `integration-tests/tests/smoke/common/snowflake_fixture.rs` recovers
46/// statements from arbitrary generated DDL by tracking `BEGIN`/`END` depth,
47/// `$$` bodies and quoting — the only thing that *can* be done to a script
48/// nobody declared a boundary in. It is not this, and neither replaces the
49/// other: that one reads scripts razeri did not mark, this one reads the
50/// prerequisite scripts razeri does. A future sweep that unifies them should
51/// move the smoke fixture onto the declared boundary, not the other way round.
52pub const SID_PREREQUISITE_STATEMENT_DELIMITER: &str =
53    "-- ──── RAZERI SID PREREQUISITE STATEMENT ────";
54
55/// **The input every generated prerequisite probe asks about.**
56///
57/// One word, and it is the one [`ReplicationRenderer::sid_validation_query`]
58/// already puts in its `SELECT`. It lives here rather than in the bundle
59/// generator because three producers must agree on it and they live in
60/// different crates: the dialect writing the operator's instructions, the
61/// generator writing the deploy script's comparison, and the validation query
62/// itself. A probe that asked about one word while the instructions promised
63/// another is a hand-spelled second variant — the shape that once shipped an
64/// installer calling a function the engine does not have, with no test ever
65/// running it.
66pub const SID_PROBE_INPUT: &str = "test";
67
68// ════════════════════════════════════════════════════════════════════════
69// SID Prerequisites — what KIND of step this platform's is
70// ════════════════════════════════════════════════════════════════════════
71
72/// **What a platform's SID prerequisite actually asks of whoever deploys.**
73///
74/// The bundle's prerequisites step has to emit a different *shape* for each of
75/// these, and the deploy script a different *behaviour*: one runs SQL, one
76/// verifies and refuses, one refuses outright, one says there is nothing to do.
77/// Sniffing that out of the prose at each call site would put the rule in the
78/// generator, the script writer and the runner — three readings of one fact,
79/// which is exactly how one derivation becomes two.
80///
81/// The default derives it from what the dialect already says, so a dialect that
82/// overrides nothing is still classified correctly and only the one platform
83/// the derivation cannot reach ([`SQLite`](Self::Refused)) has to override.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum SidPrerequisiteKind {
86    /// **Nothing to install: the engine's own hash already IS the canonical
87    /// number** (XXH64 seed 0), reachable from SQL razeri emits.
88    ///
89    /// **A positive claim, and therefore never a default.** Saying this means
90    /// razeri has *measured* that the platform's builtin returns XXH64 seed 0 —
91    /// not that nobody has said otherwise. A dialect that has declared nothing
92    /// gets [`Undeclared`](Self::Undeclared) instead, for the same reason
93    /// `DdlRenderer::representable_timestamp_ceiling`'s `None` means *no
94    /// measured ceiling* rather than *holds everything*.
95    ///
96    /// The derivation cannot reach this variant. A dialect claiming it
97    /// **overrides [`ReplicationRenderer::sid_prerequisite_kind`] and cites the
98    /// measurement**, which is what `ClickHouseDialect` does.
99    None,
100
101    /// Razeri emits statements that install it, and a deploy script can run
102    /// them unattended: `INSTALL`/`LOAD` (`DuckDB`), `CREATE FUNCTION`
103    /// (Snowflake's JavaScript UDF, Databricks' Python one).
104    ///
105    /// The test is *"can a script finish this with no human on the host"*. It is
106    /// **not** "is the statement SQL": `CREATE EXTENSION` is SQL and only
107    /// **registers** a shared object somebody compiled first, which is why
108    /// `PostgreSQL` and Citus are [`OperatorArtifact`](Self::OperatorArtifact)
109    /// and not this.
110    SqlInstallable,
111
112    /// An artifact the **operator** builds and places before any SQL can
113    /// reference it — SQL Server's CLR assembly, `MariaDB`'s and `MySQL`'s
114    /// `.so`, `PostgreSQL`'s and Citus's `pg_xxhash`. Razeri emits the source
115    /// or the build recipe as prose; it cannot compile anything, and it will not
116    /// pretend to.
117    ///
118    /// The deploy path's obligation here is the prerequisites verb: **verify,
119    /// then
120    /// refuse** — ask the engine for the function and stop in front of `schema`
121    /// when it is not there, rather than deploying a warehouse whose keys it
122    /// cannot compute.
123    OperatorArtifact,
124
125    /// The platform cannot be made to compute razeri's key from SQL **at all**,
126    /// and no artifact an operator drops in changes that.
127    ///
128    /// `SQLite` is the case and, so far, the only one: a standalone `sqlite3`
129    /// has no `CREATE FUNCTION` in its SQL surface, so there is no statement to
130    /// emit and no library to load. Razeri-*driven* `SQLite` can register a
131    /// function through `sqlite3_create_function()`, which is a C API, not SQL —
132    /// so the bundle's razeri-free operator genuinely cannot get there from
133    /// here.
134    ///
135    /// The answer is a **named refusal carried in the bundle**, checked before
136    /// any data moves. Never a silent fall-back expression — and the fall-back is
137    /// not hypothetical or historical: `SidSpelling`'s `sqlite` arm
138    /// **still** renders `ABS(UNICODE(x))`, the code point of the input's first
139    /// character. That is not a weak hash; it is not a hash.
140    /// The refusal is what stops it reaching a warehouse; the expression itself
141    /// is still what that arm would emit.
142    Refused,
143
144    /// **Nothing to install here, because razeri's own key function travels in
145    /// `schema.sql` instead** — one definition per domain, created in the
146    /// domain's own namespace.
147    ///
148    /// `BigQuery` is the case and the reason is name resolution, not taste: a
149    /// bare `generate_sid('test')` is *"Function not found: `generate_sid`; Did
150    /// you mean `generate_uuid`?"* there — no search path, no session schema, no
151    /// default dataset inside a procedure body — so every call site must name
152    /// the dataset the function was created in, and a prerequisites script has
153    /// no dataset to create it in. The definition therefore rides `schema.sql`,
154    /// where the domain's namespace is known.
155    ///
156    /// It is **not** [`None`](Self::None). That variant is the positive claim
157    /// *"the engine's own hash IS the canonical number"*, which is false here:
158    /// `BigQuery` has no XXH64 in any spelling and the key is a razeri-owned
159    /// JavaScript UDF. What the two share is only the deploy behaviour —
160    /// nothing to run before `schema.sql` — and sharing a behaviour is not
161    /// sharing a claim.
162    EmittedInSchema,
163
164    /// **Razeri has no measured answer for this platform** — the honest default,
165    /// and the one a dialect gets by saying nothing.
166    ///
167    /// # Why this variant exists
168    ///
169    /// Because the derivation used to answer [`None`](Self::None) here, turning
170    /// *"this dialect declared nothing"* into the positive claim *"its own hash
171    /// is razeri's number"*. Measured: the `agnostic` pseudo-dialect
172    /// emitted a `prerequisites.sql` reading **"NOTHING TO INSTALL"** beside a
173    /// `schema.sql` whose key helper calls `xxhash64(` — a portable *authoring*
174    /// spelling that **no engine ships**. That is the `MySQL` defect
175    /// (`MySqlDialect`'s empty `ReplicationRenderer` impl) reproduced by a
176    /// second route, from the same asymmetry.
177    ///
178    /// # What it means to a deploy path
179    ///
180    /// The same as [`OperatorArtifact`](Self::OperatorArtifact): **verify, then
181    /// refuse.** Safe-by-default is the only defensible reading of "razeri does
182    /// not know" — the doctrine allows an emitted UDF or a named refusal and
183    /// explicitly not a local approximation, and proceeding on an unmeasured
184    /// platform is neither.
185    ///
186    /// A dialect that genuinely needs no prerequisite says so by overriding
187    /// [`ReplicationRenderer::sid_prerequisite_kind`] to
188    /// [`None`](Self::None), with its measurement cited.
189    Undeclared,
190}
191
192/// **The executable statements inside a prerequisite script**, split on
193/// [`SID_PREREQUISITE_STATEMENT_DELIMITER`].
194///
195/// The body of [`ReplicationRenderer::sid_prerequisite_statements`], lifted out
196/// so a caller holding a script that is not `self.sid_prerequisites()` — the
197/// bundle, when it swaps in
198/// [`ReplicationRenderer::sid_pure_sql_fallback`] — splits it the same way
199/// rather than growing a second reading of the boundary.
200///
201/// Leading comment and blank lines are dropped from each segment and one
202/// trailing terminator is stripped; see the trait method for why both.
203#[must_use]
204pub fn statements_in_prerequisite_script(script: &str) -> Vec<String> {
205    if !script.contains(SID_PREREQUISITE_STATEMENT_DELIMITER) {
206        return Vec::new();
207    }
208    script
209        .split(SID_PREREQUISITE_STATEMENT_DELIMITER)
210        .filter_map(|segment| {
211            let statement = segment
212                .lines()
213                .skip_while(|line| {
214                    let line = line.trim_start();
215                    line.is_empty() || line.starts_with("--")
216                })
217                .collect::<Vec<_>>()
218                .join("\n");
219            // One trailing terminator, not a run of them: `$$;` ends a body
220            // whose own last character may be a `;` the statement needs.
221            let statement = statement.trim_end().strip_suffix(';').map_or_else(
222                || statement.trim_end().to_owned(),
223                |trimmed| trimmed.trim_end().to_owned(),
224            );
225            (!statement.is_empty()).then_some(statement)
226        })
227        .collect()
228}
229
230/// **What shape a prerequisite script is**, read off the script itself.
231///
232/// The body of [`ReplicationRenderer::sid_prerequisite_kind`]'s default, lifted
233/// out for the same reason as [`statements_in_prerequisite_script`]: a caller
234/// classifying a script the dialect did not return from `sid_prerequisites()`
235/// must reach the same three answers by the same rule. A second reading of the
236/// table in the trait method's own doc comment is how a script comes to be
237/// classified two ways.
238///
239/// The two answers this cannot reach — [`SidPrerequisiteKind::None`] and
240/// [`SidPrerequisiteKind::Refused`] — are positive claims about an engine, and
241/// a dialect makes them by overriding, never by writing a script this reads.
242#[must_use]
243pub fn kind_of_prerequisite_script(script: &str) -> SidPrerequisiteKind {
244    if script.trim().is_empty() {
245        SidPrerequisiteKind::Undeclared
246    } else if statements_in_prerequisite_script(script).is_empty() {
247        SidPrerequisiteKind::OperatorArtifact
248    } else {
249        SidPrerequisiteKind::SqlInstallable
250    }
251}
252
253// ════════════════════════════════════════════════════════════════════════
254// The external artifact that IS `generate_sid`
255// ════════════════════════════════════════════════════════════════════════
256
257/// **The identity of the artifact the bundle's prerequisites step installs as
258/// `generate_sid`** — where one exists.
259///
260/// Returned by [`ReplicationRenderer::sid_external_artifact`], and the only
261/// thing a dialect still has to say about the key function's *body*: on every
262/// platform where razeri owns the name the body is
263/// Razeri's one surrogate-key derivation and reads out of one table, so
264/// there is nothing per-dialect left to declare.
265///
266/// # What this replaced — a producer nothing installs from only drifts
267///
268/// This was `SidFunctionSpec`, an enum with three arms. Two of them —
269/// `SqlExpression` (the trait default, re-spelling the derivation over
270/// `input_text`) and `ProceduralBlock` (`PostgresDialect`'s PL/pgSQL block) —
271/// **lost their emitter** when the replication
272/// precheck was pointed at the one envelope
273/// (`razeri_sql::domain::scalar_function::generate_sid_helper`). Nothing
274/// installed from them afterwards; only `razeri-materialize`'s census still
275/// read them, which kept them from drifting but not from being dead. Retiring
276/// them shrinks that census's producer count from four to three by an honest
277/// count rather than by a quiet skip, and it took a live defect with it: on
278/// `SQLite` the dead `SqlExpression` default was still reachable through the
279/// bindings' `generate_sid_function_ddl`, where it rendered a `CREATE` around
280/// `ABS(UNICODE(x))` — the code point of the input's first character, which is
281/// not a hash — and around a parameter it did not declare.
282///
283/// The `input_text` convention died with those arms. `generate_sid`'s
284/// parameter has **one** name, `input_value`, the envelope's.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct SidExternalArtifact {
287    /// Language name, e.g. `"tsql"`, `"C"`, `"javascript"`, `"python"`.
288    pub language: String,
289    /// Path or name of the external library, assembly, or emitted definition.
290    pub object_file: String,
291    /// Fully-qualified symbol within the library.
292    pub link_symbol: String,
293    /// Descriptive comment for the function — the **dialect's**, because *why
294    /// the platform's built-in was refused* is a platform fact.
295    pub comment: String,
296}
297
298// ════════════════════════════════════════════════════════════════════════
299// ReplicationRenderer Trait
300// ════════════════════════════════════════════════════════════════════════
301
302/// Rendering methods for replication operations: SID generation, data
303/// extraction, data loading, and checksum validation.
304///
305/// Defaults produce ANSI-safe SQL so extension dialects (e.g., `SQLite`) work
306/// without implementing every method.
307pub trait ReplicationRenderer: SqlRenderer {
308    // ── SID (Surrogate ID) Generation ────────────────────────────────
309
310    /// **Does an external artifact already carry the name `generate_sid` on
311    /// this platform, and what is it?**
312    ///
313    /// `None` — the default, and the honest answer for most engines — means
314    /// razeri itself defines the key function here: the body is
315    /// Razeri's one surrogate-key derivation with the platform's own hash inlined,
316    /// composed by
317    /// `razeri_sql::domain::scalar_function::generate_sid_helper` from the
318    /// platform name alone, so there is nothing for a dialect to spell.
319    ///
320    /// `Some(…)` names what the bundle's prerequisites step installs: SQL
321    /// Server's CLR assembly, MySQL/MariaDB's C UDF, Snowflake's emitted
322    /// JavaScript UDF, Databricks' emitted Python UDF. The derivation is inside
323    /// the artifact rather than in any SQL razeri emits, so what razeri can
324    /// still state — and what `razeri-materialize`'s census grades — is the
325    /// artifact's *identity*, matched against the very prerequisites text that
326    /// installs it.
327    ///
328    /// **This is not a body, and it never was one.** It used to be
329    /// `sid_function_spec`, which could also answer with SQL razeri would then
330    /// install; see [`SidExternalArtifact`] for what that cost and why the two
331    /// expression-carrying arms are gone.
332    ///
333    /// A platform that can be given no function at all — `SQLite`,
334    /// which has no `CREATE FUNCTION` in its SQL surface — answers `None`
335    /// here too and says so through
336    /// [`Self::sid_prerequisite_kind`] ([`SidPrerequisiteKind::Refused`]).
337    ///
338    /// **The two are read by two different readers, and the DDL builder is not
339    /// the one that reads both.** `generate_sid_function_from_dialect`
340    /// refuses on `!emits_key_helper(dialect) && sid_external_artifact().is_none()`
341    /// — this method's `None` is the whole of its second condition, and it
342    /// never consults the kind. What reads the **pair** is `razeri-materialize`'s
343    /// census: where no artifact is declared and razeri emits no helper either,
344    /// it requires the dialect to have said `Refused` out loud rather than
345    /// having simply declared nothing, which is the distinction
346    /// [`SidPrerequisiteKind::Undeclared`] exists to keep.
347    fn sid_external_artifact(&self) -> Option<SidExternalArtifact> {
348        None
349    }
350
351    /// Returns prerequisite SQL/instructions for SID deployment.
352    ///
353    /// For platforms that require extensions or external libraries (`PostgreSQL`
354    /// `pg_xxhash`, SQL Server CLR, `MariaDB` C UDF), this returns the deployment
355    /// script. Default: empty (no prerequisites needed).
356    fn sid_prerequisites(&self) -> String {
357        String::new()
358    }
359
360    /// **The executable statements inside [`Self::sid_prerequisites`], in
361    /// order** — razeri's own text, split on razeri's own declared boundary.
362    ///
363    /// This exists so an equality-gate arm can *install what razeri emits*
364    /// rather than a transcription of it — a hand-written copy in a test is a
365    /// second implementation that drifts. The `MariaDB` arm already does this by hand
366    /// for a C comment block; the two cloud arms need SQL statements, and a
367    /// second splitter living in the test tree would be a second definition of
368    /// the boundary.
369    ///
370    /// **A dialect that does not write
371    /// [`SID_PREREQUISITE_STATEMENT_DELIMITER`] declares no executable
372    /// statements**, and gets an empty vector. That is the honest answer for
373    /// every arm whose prerequisite is an operator's compile step (SQL Server's
374    /// CLR assembly, `MariaDB`'s and `MySQL`'s `.so`) and for the platforms with
375    /// no prerequisite at all — it is *not* a promise that a caller can run the
376    /// prose.
377    ///
378    /// Leading comment and blank lines are dropped from each segment, so the
379    /// prose that explains a statement travels with it in the script and does
380    /// not travel to the engine. Comments *inside* a statement survive: they are
381    /// part of the statement the operator would paste.
382    ///
383    /// **The trailing terminator is dropped here, once.** A `;` is how the
384    /// script reads to a human pasting the whole file, and is not part of a
385    /// single statement over a REST statement API — Snowflake's and Databricks'
386    /// both take exactly one. Stripping it in each consumer instead would put
387    /// the same rule in the cloud arms *and* in the bundle executor, which
388    /// is the shape this method exists to prevent. `sid_prerequisites()`' text
389    /// is unchanged and keeps its semicolons.
390    fn sid_prerequisite_statements(&self) -> Vec<String> {
391        statements_in_prerequisite_script(&self.sid_prerequisites())
392    }
393
394    /// **A prerequisite script that installs the same key function without
395    /// compiling anything** — where the platform has one, and `None` where it
396    /// does not.
397    ///
398    /// The default is `None`: most platforms have exactly one prerequisite and
399    /// [`Self::sid_prerequisites`] is it. A dialect answers `Some` only where
400    /// **two honest routes to the same number** exist and the choice belongs to
401    /// whoever deploys rather than to whoever designed — the case being a
402    /// managed `PostgreSQL` service (`RDS`, Aurora, Cloud SQL), where nobody has
403    /// a shell on the server host and so the compiled extension the ordinary
404    /// step names can never be built.
405    ///
406    /// # What answering `Some` promises
407    ///
408    /// **The same number, byte for byte.** Not a near-enough hash and not a
409    /// different algorithm: the surrogate-key law is one hash and one
410    /// derivation everywhere, so a key minted through this route and a key
411    /// minted through the compiled one are the same `BIGINT` and a warehouse can
412    /// be moved between them without re-keying a row. A dialect that cannot
413    /// promise that answers `None` and lets the ordinary step refuse.
414    ///
415    /// It is a **deploy-time** choice and nothing about it reaches a design: the
416    /// text swaps into `prerequisites.sql` alone, `schema.sql` is unchanged, and
417    /// every call site still calls the one public name.
418    fn sid_pure_sql_fallback(&self) -> Option<String> {
419        None
420    }
421
422    /// **Which of [`SidPrerequisiteKind`]'s shapes this platform's prerequisite
423    /// is.**
424    ///
425    /// Derived from what the dialect already says, so the classification cannot
426    /// drift from the text it classifies:
427    ///
428    /// | [`sid_prerequisites`](Self::sid_prerequisites) | [`sid_prerequisite_statements`](Self::sid_prerequisite_statements) | kind |
429    /// |---|---|---|
430    /// | empty | — | [`Undeclared`](SidPrerequisiteKind::Undeclared) |
431    /// | prose **and** statements | non-empty | [`SqlInstallable`](SidPrerequisiteKind::SqlInstallable) |
432    /// | prose only | empty | [`OperatorArtifact`](SidPrerequisiteKind::OperatorArtifact) |
433    ///
434    /// # Silence is `Undeclared`, never `None`
435    ///
436    /// The two answers the derivation **cannot** reach are the two that are
437    /// *positive claims about an engine*:
438    ///
439    /// - [`None`](SidPrerequisiteKind::None) says the platform's own builtin
440    ///   returns the canonical number. That is a measurement, not an absence, and
441    ///   a dialect claiming it overrides this method and cites the arm that
442    ///   measured it;
443    /// - [`Refused`](SidPrerequisiteKind::Refused) says no SQL can ever install
444    ///   the function here, which a platform with a native hash and a lazy
445    ///   dialect author would also spell as silence.
446    ///
447    /// This derivation used to answer `None` for silence, and it was wrong twice
448    /// over on real dialects: `MySqlDialect`'s empty `ReplicationRenderer` impl,
449    /// and the `agnostic` pseudo-dialect whose emitted helper calls `xxhash64(`
450    /// — a spelling no engine ships (measured, both through a
451    /// generated bundle). It is the same asymmetry
452    /// `DdlRenderer::representable_timestamp_ceiling` documents on its own
453    /// `None`: *not measured* and *nothing to worry about* are opposite facts
454    /// that read identically when a default supplies them.
455    fn sid_prerequisite_kind(&self) -> SidPrerequisiteKind {
456        kind_of_prerequisite_script(&self.sid_prerequisites())
457    }
458
459    /// Returns a validation query to verify the SID function is operational.
460    ///
461    /// Default: schema-qualified `SELECT generate_sid('test')`.
462    fn sid_validation_query(&self, schema: Option<&str>, null_sid: i64) -> String {
463        let qualified = match schema {
464            Some(s) => format!("{s}.generate_sid"),
465            None => "generate_sid".to_string(),
466        };
467        format!("SELECT {qualified}('test') AS test_sid, {null_sid} AS null_sid;\n")
468    }
469
470    // ── Data Extraction ──────────────────────────────────────────────
471
472    /// Renders an extract/export SQL statement for the given table.
473    ///
474    /// The `format` parameter is one of: `"csv"`, `"parquet"`, `"json_lines"`, `"native"`.
475    ///
476    /// Default: ANSI `SELECT ... FROM ... WHERE ...` (no COPY).
477    fn render_data_extract(
478        &self,
479        qualified: &str,
480        columns: &str,
481        file_path: &str,
482        format: &str,
483        where_clause: &str,
484        _batch_size: Option<usize>,
485    ) -> String {
486        let _ = file_path;
487        let _ = format;
488        if where_clause.is_empty() {
489            format!("SELECT {columns} FROM {qualified};\n")
490        } else {
491            format!("SELECT {columns} FROM {qualified} {where_clause};\n")
492        }
493    }
494
495    // ── Data Loading ─────────────────────────────────────────────────
496
497    /// Renders a load/import SQL statement for the given table.
498    ///
499    /// The `format` parameter is one of: `"csv"`, `"parquet"`, `"json_lines"`, `"native"`.
500    ///
501    /// Default: ANSI comment placeholder.
502    fn render_data_load(&self, qualified: &str, file_path: &str, _format: &str) -> String {
503        format!("-- COPY INTO {qualified} FROM '{file_path}';\n")
504    }
505
506    // ── Checksum / Validation ────────────────────────────────────────
507
508    /// Renders a checksum validation query for data comparison.
509    ///
510    /// Default: `SELECT COUNT(*) AS cnt FROM <table>` (no checksum).
511    fn render_checksum_query(&self, qualified: &str) -> String {
512        format!("SELECT COUNT(*) AS cnt FROM {qualified};\n")
513    }
514
515    // ── Platform Warnings ────────────────────────────────────────────
516
517    /// Warning about Parquet export limitations on this platform.
518    ///
519    /// Default: `None` (no warning needed).
520    fn parquet_export_warning(&self) -> Option<&str> {
521        None
522    }
523
524    /// Warning about SID function deployment requirements on this platform.
525    ///
526    /// Default: `None` (no special deployment needed).
527    fn sid_deployment_warning(&self) -> Option<&str> {
528        None
529    }
530
531    /// General replication caveats for this platform as a target.
532    ///
533    /// Default: empty (no platform-specific caveats).
534    fn replication_target_warnings(&self) -> Vec<String> {
535        Vec::new()
536    }
537
538    /// Warning about TRUNCATE behavior on this platform.
539    ///
540    /// Default: `None` (TRUNCATE works normally).
541    fn truncate_caveat(&self) -> Option<&str> {
542        None
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::{ReplicationRenderer, SID_PREREQUISITE_STATEMENT_DELIMITER};
549    use crate::agnostic::AnsiDialect;
550
551    /// A dialect whose prerequisites are whatever a test hands it.
552    struct Emitting(String);
553
554    impl crate::SqlRenderer for Emitting {
555        fn dialect_name(&self) -> &'static str {
556            "emitting"
557        }
558        fn render_data_type(&self, dt: &crate::DataType) -> String {
559            AnsiDialect.render_data_type(dt)
560        }
561        fn quote_identifier(&self, name: &str) -> String {
562            AnsiDialect.quote_identifier(name)
563        }
564        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
565            AnsiDialect.qualify_name(schema, name)
566        }
567        fn render_current_timestamp(&self) -> String {
568            AnsiDialect.render_current_timestamp()
569        }
570    }
571    impl crate::DdlRenderer for Emitting {}
572    impl crate::FormulaRenderer for Emitting {}
573    impl ReplicationRenderer for Emitting {
574        fn sid_prerequisites(&self) -> String {
575            self.0.clone()
576        }
577    }
578
579    /// **A JavaScript body is nothing but semicolons**, which is exactly why the
580    /// boundary is declared instead of inferred. Splitting the text below on
581    /// `;` yields five fragments, none of them a statement.
582    #[test]
583    fn the_delimiter_recovers_statements_a_semicolon_split_would_fracture() {
584        let script = format!(
585            "-- prose the operator reads\n\
586             {SID_PREREQUISITE_STATEMENT_DELIMITER}\n\
587             -- why this one exists\n\
588             CREATE FUNCTION f() AS $$\n  var a = 1; var b = 2; return a + b;\n$$;\n\
589             {SID_PREREQUISITE_STATEMENT_DELIMITER}\n\
590             CREATE FUNCTION g() AS $$ 1 $$;\n"
591        );
592        let statements = Emitting(script).sid_prerequisite_statements();
593        assert_eq!(statements.len(), 2, "{statements:#?}");
594        assert!(statements[0].starts_with("CREATE FUNCTION f()"), "{:?}", statements[0]);
595        assert!(statements[0].contains("var a = 1; var b = 2; return a + b;"));
596        assert_eq!(statements[1], "CREATE FUNCTION g() AS $$ 1 $$");
597    }
598
599    /// **The trailing terminator is dropped exactly once, and only at the end.**
600    ///
601    /// A REST statement API takes one statement and no terminator; a human
602    /// pasting the script wants the `;`. Both are served by leaving it in
603    /// `sid_prerequisites()`' text and removing it here — once, so a body whose
604    /// own last character is a `;` (every JavaScript function) keeps it.
605    #[test]
606    fn one_trailing_terminator_is_dropped_and_the_bodys_own_semicolons_survive() {
607        let script = format!(
608            "{SID_PREREQUISITE_STATEMENT_DELIMITER}\n\
609             CREATE FUNCTION f() AS $$\n  return 1;\n$$;\n\
610             {SID_PREREQUISITE_STATEMENT_DELIMITER}\n\
611             CREATE FUNCTION g() AS $$ 1 $$\n"
612        );
613        let statements = Emitting(script).sid_prerequisite_statements();
614        assert_eq!(statements[0], "CREATE FUNCTION f() AS $$\n  return 1;\n$$");
615        assert!(statements[0].contains("return 1;"), "the body's own `;` is not a terminator");
616        assert_eq!(
617            statements[1], "CREATE FUNCTION g() AS $$ 1 $$",
618            "a statement with no terminator is unchanged"
619        );
620    }
621
622    /// **No delimiter means no executable statements, and that is an answer.**
623    /// The prerequisite scripts for SQL Server, `MySQL` and `MariaDB` are an
624    /// operator's compile recipe wrapped in comments; handing a caller the prose
625    /// as if it were DDL is how a deferral turns into a failure nobody can read.
626    #[test]
627    fn an_undelimited_script_declares_no_executable_statements() {
628        let operator_recipe = "-- compile this yourself:\n/* int main() { } */\n".to_owned();
629        assert!(Emitting(operator_recipe).sid_prerequisite_statements().is_empty());
630        assert!(AnsiDialect.sid_prerequisite_statements().is_empty());
631    }
632}