Skip to main content

razeri_plugin_api/
conformance.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//! **Every obligation a dialect owes, reported at once.**
6//!
7//! Two people built dialects against Razeri's authoring guide without help.
8//! Between them they met twenty-seven obligations one `cargo test` at a time —
9//! a roster fails, you add a row, the next roster fails. Not one of those
10//! failures was discoverable before it fired. This module is the report that
11//! answers all of them in one pass.
12//!
13//! # Running it
14//!
15//! From your own crate's tests, where your dialect is linked and Razeri's
16//! registry can see it:
17//!
18//! ```no_run
19//! # use razeri_plugin_api::conformance;
20//! # fn example(my_dialect: &dyn razeri_plugin_api::PlatformDialect) {
21//! let report = conformance::check(my_dialect);
22//! assert!(report.is_clean(), "{report}");
23//! # }
24//! ```
25//!
26//! # What it can and cannot see
27//!
28//! Everything here is read off the dialect object, so it works wherever your
29//! plugin compiles: it grades what your dialect declares and what its
30//! renderers return. It runs no SQL, so anything only an engine can settle —
31//! whether the SQL your dialect emits is the SQL that engine accepts — stays
32//! yours to test against a real instance.
33//!
34//! # Your dialect's name
35//!
36//! Your platform's names are yours. `DialectInfo::name` is the one Razeri
37//! reports, `aliases` is every other spelling a design may use for it, and the
38//! resolver reads both off the dialects that are loaded — so the report asks
39//! only that your own spellings reach you, and there is no table inside Razeri
40//! for your platform to be missing from. The one thing it will tell you is that
41//! a spelling you declared is one another loaded platform also answers to,
42//! because then the spelling names neither of you.
43
44use crate::PlatformDialect;
45use razeri_plugin_contract::{
46    ColumnEvolution, ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities,
47    DialectFamily, EntityRef, MergeRestrictions, MergeShape, ProcedureForm, RoutineKind,
48    RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer, StorageRenderer,
49    TableFunctionForm, UNKNOWN_PLATFORM,
50    data_type::{ArrayDataType, MapDataType, StructDataType, StructField},
51    storage_strategy::{
52        CompressionStrategy, IndexStrategy, PartitionGranularity, PartitionStrategy, StoragePlan,
53    },
54};
55use std::fmt;
56
57/// How much a finding matters.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum Severity {
60    /// **This dialect will produce SQL its engine rejects.** Fix it before
61    /// anything else.
62    Blocking,
63    /// **A question this dialect has not answered.** Razeri has a default and
64    /// will use it; the default was written for a different engine.
65    Unanswered,
66    /// Worth a look. Nothing is broken.
67    Note,
68}
69
70impl fmt::Display for Severity {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(match self {
73            Self::Blocking => "must fix",
74            Self::Unanswered => "unanswered",
75            Self::Note => "worth a look",
76        })
77    }
78}
79
80/// One thing to fix, in the order a reader wants it: what is wrong, then what
81/// to do about it.
82#[derive(Debug, Clone)]
83pub struct Finding {
84    /// How much it matters.
85    pub severity: Severity,
86    /// One line naming what is wrong.
87    pub what: String,
88    /// One line naming what to do.
89    pub fix: String,
90}
91
92/// The whole report for one dialect.
93#[derive(Debug, Clone)]
94pub struct Report {
95    /// The dialect's registry name.
96    pub dialect: String,
97    /// Everything found, worst first.
98    pub findings: Vec<Finding>,
99}
100
101impl Report {
102    /// Whether nothing blocking or unanswered was found.
103    ///
104    /// Notes do not fail a report: they are observations, not obligations.
105    #[must_use]
106    pub fn is_clean(&self) -> bool {
107        !self.findings.iter().any(|f| f.severity != Severity::Note)
108    }
109
110    /// How many findings of one severity.
111    #[must_use]
112    pub fn count(&self, severity: Severity) -> usize {
113        self.findings.iter().filter(|f| f.severity == severity).count()
114    }
115}
116
117impl fmt::Display for Report {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        let name = &self.dialect;
120        if self.findings.is_empty() {
121            return writeln!(
122                f,
123                "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
124            );
125        }
126        let blocking = self.count(Severity::Blocking);
127        let unanswered = self.count(Severity::Unanswered);
128        let notes = self.count(Severity::Note);
129        writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
130        for finding in &self.findings {
131            writeln!(f)?;
132            writeln!(f, "  [{}] {}", finding.severity, finding.what)?;
133            writeln!(f, "      → {}", finding.fix)?;
134        }
135        Ok(())
136    }
137}
138
139/// **The report.** Everything about this dialect that Razeri can check by
140/// asking it.
141///
142/// Registration is checked against the dialects linked into this program. A
143/// dialect the host loaded at run time is not among them, so report it with
144/// [`check_with_registry`] and the registry it was loaded into.
145#[must_use]
146pub fn check(dialect: &dyn PlatformDialect) -> Report {
147    check_with_registry(dialect, &crate::dialect::PlatformRegistry::with_auto_discovered())
148}
149
150/// The same report, against a registry the host built — the path for a dialect
151/// loaded at run time rather than linked in.
152#[must_use]
153pub fn check_with_registry(
154    dialect: &dyn PlatformDialect,
155    registry: &crate::dialect::PlatformRegistry,
156) -> Report {
157    let info = dialect.info();
158    let mut findings = Vec::new();
159
160    check_registration(&info, registry, &mut findings);
161    check_family(info.dialect_family, &mut findings);
162    check_required_rendering(dialect, &mut findings);
163    check_decisions(dialect, &mut findings);
164    check_formula_renderer(dialect, &mut findings);
165    check_evolution(dialect, &mut findings);
166    check_paired_facts(dialect, &mut findings);
167
168    findings.sort_by_key(|f| f.severity);
169    Report { dialect: info.name.to_owned(), findings }
170}
171
172/// Every spelling this dialect answers to has to reach this dialect: its own
173/// name, and each alias it declares.
174///
175/// That is the whole obligation, and it is one the author can meet from their
176/// own crate — the spellings are the dialect's own, so there is no list inside
177/// Razeri to be missing from and nothing here can ask for a change only Razeri
178/// could make. What is worth saying is a spelling that reaches somewhere else:
179/// one another loaded platform also claims (so it names neither, and Razeri
180/// refuses it), or one that reaches a different platform because the registry
181/// holds this dialect under another key.
182fn check_registration(
183    info: &crate::dialect::DialectInfo,
184    registry: &crate::dialect::PlatformRegistry,
185    findings: &mut Vec<Finding>,
186) {
187    let name = info.name;
188    if matches!(
189        crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
190        Err(crate::ResolveError::UnknownDialect { .. })
191    ) {
192        findings.push(Finding {
193            severity: Severity::Blocking,
194            what: format!(
195                "the name `{name}` does not resolve — nothing in this program registered it"
196            ),
197            fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
198                  Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
199                  it that the program using your dialect calls once. A crate nothing links is a \
200                  crate the registry cannot see."
201                .to_owned(),
202        });
203        return;
204    }
205    for spelling in info.spellings() {
206        match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
207            Ok(found) if found.info().name == name => {}
208            Ok(found) => findings.push(Finding {
209                severity: Severity::Blocking,
210                what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
211                fix: format!(
212                    "the registry holds `{name}` under another key. Register it under its own \
213                     name, or drop `{spelling}` from its aliases."
214                ),
215            }),
216            Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
217                severity: Severity::Blocking,
218                what: format!(
219                    "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
220                     none of them — Razeri refuses that spelling rather than guessing which one \
221                     a design meant",
222                    claimed_by.join(" and ")
223                ),
224                fix: format!(
225                    "give this dialect a name no other platform answers to. A platform name is \
226                     read off the loaded dialects' own declared spellings, so a spelling another \
227                     platform already claims is never `{name}`'s."
228                ),
229            }),
230            Err(_) => findings.push(Finding {
231                severity: Severity::Blocking,
232                what: format!(
233                    "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
234                ),
235                fix: format!(
236                    "the `{name}` in the registry is a different build of this dialect and does \
237                     not declare `{spelling}`. Register the one you are grading."
238                ),
239            }),
240        }
241    }
242}
243
244/// A family is a claim about spelling, so an unmeasured engine claiming a
245/// measured family's name licenses SQL it may reject.
246fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
247    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
248        findings.push(Finding {
249            severity: Severity::Blocking,
250            what: "this dialect's family is a custom one with no name".to_owned(),
251            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
252                  prints and what a reader looks up."
253                .to_owned(),
254        });
255    }
256}
257
258/// The four required methods must answer for every type a design can declare.
259fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
260    let declarable = declarable_types();
261    let empty_column: Vec<String> = declarable
262        .iter()
263        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
264        .map(|dt| format!("{dt:?}"))
265        .collect();
266    if !empty_column.is_empty() {
267        findings.push(Finding {
268            severity: Severity::Blocking,
269            what: format!(
270                "`render_data_type` answers nothing for {} of the types a design can declare",
271                empty_column.len()
272            ),
273            fix: "every declarable type needs a column spelling on this engine, even if the \
274                  spelling is a wider type that stores it. A blank answer becomes a column \
275                  declaration with no type in it."
276                .to_owned(),
277        });
278    }
279    let empty_cast: Vec<String> = declarable
280        .iter()
281        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
282        .map(|dt| format!("{dt:?}"))
283        .collect();
284    if !empty_cast.is_empty() {
285        findings.push(Finding {
286            severity: Severity::Blocking,
287            what: format!(
288                "`render_cast_type` answers nothing for {} declarable types",
289                empty_cast.len()
290            ),
291            fix: "the generated load opens by casting every source column to its declared type, \
292                  so a blank answer fails the load on its first statement."
293                .to_owned(),
294        });
295    }
296    let quoted = SqlRenderer::quote_identifier(dialect, "order");
297    if quoted == "order" {
298        findings.push(Finding {
299            severity: Severity::Blocking,
300            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
301                   reaches this engine bare"
302                .to_owned(),
303            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
304                  backticks, or brackets."
305                .to_owned(),
306        });
307    }
308    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
309        findings.push(Finding {
310            severity: Severity::Blocking,
311            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
312                   a row with it"
313                .to_owned(),
314            fix: "return this engine's own expression for the current moment.".to_owned(),
315        });
316    }
317}
318
319/// **Reading a file in a named encoding**, where the dialect states a way to.
320///
321/// The same path question [`check_decisions`] asks of the plain relation, for
322/// the same reason, plus one only this method can get wrong: an engine that
323/// reads no file at all cannot read one in a named encoding, and a dialect that
324/// answers here and not there states a relation nothing will ever ask it for.
325///
326/// Parameters:
327/// - `dialect`: the plugin under test.
328/// - `probe`: the same quoted path literal the plain relation was asked about.
329/// - `findings`: where anything wrong is recorded.
330fn check_encoded_file_relation(
331    dialect: &dyn PlatformDialect,
332    probe: &str,
333    findings: &mut Vec<Finding>,
334) {
335    let Some(relation) =
336        SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
337    else {
338        return;
339    };
340    if !relation.contains(probe) {
341        findings.push(Finding {
342            severity: Severity::Blocking,
343            what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
344                   the path it was given"
345                .to_owned(),
346            fix: "put the literal you were handed where your engine's file relation takes a \
347                  path. It arrives already quoted and escaped."
348                .to_owned(),
349        });
350    }
351    if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
352        findings.push(Finding {
353            severity: Severity::Blocking,
354            what: "this dialect states how to read a file in a named encoding and states no way \
355                   to read a file at all"
356                .to_owned(),
357            fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
358                  first question before the second — a source whose bytes are UTF-8 never \
359                  reaches the encoding arm — so an engine that answers only the second has \
360                  written a relation nothing will ask it for."
361                .to_owned(),
362        });
363    }
364}
365
366/// The decisions a dialect states rather than inherits.
367fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
368    let spelling = SqlRenderer::sid_spelling(dialect);
369    if spelling.platform == UNKNOWN_PLATFORM {
370        findings.push(Finding {
371            severity: Severity::Blocking,
372            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
373                   an `xxhash64` its engine very likely does not have"
374                .to_owned(),
375            fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
376                  TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
377                  hash. If it has no portable hash, name an artifact your prerequisites install \
378                  — never the nearest-looking built-in, which keys a warehouse to a number \
379                  nothing else can reproduce."
380                .to_owned(),
381        });
382    }
383
384    // **A stated body that says nothing is worse than no body at all.** Razeri
385    // writes the `CREATE FUNCTION` envelope around whatever comes back, so an
386    // empty one deploys cleanly and returns nothing — every key in the
387    // warehouse is then the sentinel or garbage, under a green deploy.
388    if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
389        && supplied.body.trim().is_empty()
390    {
391        findings.push(Finding {
392            severity: Severity::Blocking,
393            what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
394                   and then supplies an empty one"
395                .to_owned(),
396            fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
397                  one argument, or answer `None` and let Razeri compose the derivation from \
398                  your `sid_spelling`. An empty body deploys without error and keys every row \
399                  to nothing."
400                .to_owned(),
401        });
402    }
403
404    // A file relation that drops its path reads the same file for every entity
405    // — one table filled, the rest filled wrong, and nothing about it fails.
406    let probe = "'/razeri/probe.csv'";
407    if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
408        && !relation.contains(probe)
409    {
410        findings.push(Finding {
411            severity: Severity::Blocking,
412            what: "`delimited_file_relation` returns a relation that does not carry the path it \
413                   was given"
414                .to_owned(),
415            fix: "put the literal you were handed where your engine's file relation takes a \
416                  path. It arrives already quoted and escaped. A relation that drops it reads \
417                  one file for every entity the design binds, and the generated fill loads the \
418                  wrong rows without failing."
419                .to_owned(),
420        });
421    }
422
423    check_encoded_file_relation(dialect, probe, findings);
424    check_routine_forms(dialect, findings);
425    check_slice_replacement(dialect, findings);
426
427    // The note is what a reader of the DDL learns from, and on an engine with
428    // no keyword of its own it is the ONLY place they can learn it.
429    if let ConstraintEnforcement::DeclaredOnly(declared) =
430        DdlRenderer::constraint_enforcement(dialect)
431        && declared.note.split_whitespace().count() < 5
432    {
433        findings.push(Finding {
434            severity: Severity::Blocking,
435            what: "this dialect says its engine accepts constraints it does not enforce, and \
436                   the note explaining that says almost nothing"
437                .to_owned(),
438            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
439                  guarantee. Say what this engine really does with the declaration, and where \
440                  the guarantee has to come from instead."
441                .to_owned(),
442        });
443    }
444
445    // **The two refusals are different findings, and that is the point of the
446    // second variant.** `None` is silence, and silence is unanswered. A stated
447    // `Unsupported` is a measured claim; what is left to grade there is whether
448    // the sentence is worth reading, because it is the only thing an operator
449    // gets where the load would have been.
450    match DialectCapabilities::merge_shape(dialect) {
451        MergeShape::None => findings.push(Finding {
452            severity: Severity::Unanswered,
453            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
454                   load for it"
455                .to_owned(),
456            fix: "if this engine has a MERGE or an upsert statement, say which in \
457                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
458                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
459                  operator can act on, rather than a silence."
460                .to_owned(),
461        }),
462        MergeShape::Unsupported(orientation) => {
463            check_refusal_copy("the upsert refusal", orientation, findings);
464        }
465        MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
466        _ => {}
467    }
468}
469
470/// **A dialect that names a verb for replacing one slice has to put the slice
471/// in it.**
472///
473/// Most dialects answer nothing here, and nothing is a complete answer: Razeri
474/// then writes a delete filtered to the slice, which is correct on every
475/// engine. What is worth grading is the dialect that *does* answer, because its
476/// statement runs in front of a load's write and clears rows. A statement that
477/// forgot the value it was handed does not clear the day this call was given —
478/// it clears some other day, or every day, and the load then reports success
479/// over a table it emptied.
480///
481/// The probe is a daily partition of a date column, which is the case every
482/// partition-level verb is allowed to answer for. A dialect that answers
483/// nothing to it has said the portable form is what it wants.
484fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
485    const VALUE: &str = "$razeri_slice_probe";
486    let plan = StoragePlan {
487        partition: PartitionStrategy::RangeByTimeDimension {
488            field: "razeri_probe_day".to_owned(),
489            granularity: PartitionGranularity::Day,
490            retention: None,
491            look_ahead: None,
492            window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
493        },
494        compression: CompressionStrategy::None,
495        index: IndexStrategy::None,
496    };
497    let day = DataType::date();
498    let columns = ["razeri_probe_day"];
499    let entity = EntityRef {
500        schema: Some("razeri_probe"),
501        name: "razeri_probe_table",
502        is_identity_table: false,
503        columns: &columns,
504        partition_column_type: Some(&day),
505    };
506    let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
507    let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
508    else {
509        return;
510    };
511
512    let carries = match &answer {
513        SliceReplacement::Emptied(statements) => {
514            !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
515        }
516        SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
517    };
518    // **A verb that carries the slice is graded nowhere**, deliberately. The
519    // kit cannot tell a measured answer from a lucky one, and a note on every
520    // dialect that got it right would fire on exactly the dialects that need no
521    // help. What is left to say is said where it is useful — in the trait's own
522    // documentation, which an author reads before writing the override.
523    if carries {
524        return;
525    }
526    findings.push(Finding {
527        severity: Severity::Blocking,
528        what: "`render_slice_replacement` returns a statement that does not carry the slice it \
529               was handed"
530            .to_owned(),
531        fix: "put the value you were given where your statement names the slice, and build any \
532              partition NAME from it the same way your extend procedure built it. A replacement \
533              that drops the value clears a slice this call was not given — or every slice — \
534              and the load reports success over what it emptied."
535            .to_owned(),
536    });
537}
538
539/// **The two halves of an evolution, held together**: what this engine says it
540/// keeps when a loaded table changes shape, and the statement it writes for the
541/// change.
542///
543/// An answer left at its default is graded nowhere, for the reason
544/// [`check_merge_restrictions`] states: from outside, a measured *"my engine
545/// does not do that"* and a question nobody asked read the same, and a finding
546/// made out of that silence would fire on every dialect.
547///
548/// What is worth saying is a **pair that disagrees**. A dialect that says its
549/// engine widens a string in place and then writes no statement for a widening
550/// has told Razeri to call the change safe and left the operator to work out
551/// the `ALTER` alone — a gap that reads to them as Razeri not having finished.
552/// And a dialect that answers `None` to a new column has declined the one
553/// evolution every warehouse has, which is nearly always an override that meant
554/// to narrow something and narrowed everything.
555fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
556    let answers = DialectCapabilities::evolution_answers(dialect);
557    let boolean = DataType::boolean();
558    let long = DataType::varchar(40);
559
560    if DdlRenderer::render_column_evolution(
561        dialect,
562        &ColumnEvolution::AddColumn {
563            schema: Some("s"),
564            table: "t",
565            column: "c",
566            data_type: &boolean,
567        },
568    )
569    .is_none()
570    {
571        findings.push(Finding {
572            severity: Severity::Blocking,
573            what: "this dialect writes no statement for a column that is new, which is the                    commonest change a deployed warehouse ever sees"
574                .to_owned(),
575            fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the                   override and take the contract's `ALTER TABLE … ADD COLUMN …`, which is this                   spelling on every engine Razeri reaches. An engine that genuinely has no way                   to add a column is worth saying out loud in the crate's own documentation."
576                .to_owned(),
577        });
578    }
579
580    if answers.parameterized_string_widening_is_lossless
581        && DdlRenderer::render_column_evolution(
582            dialect,
583            &ColumnEvolution::WidenColumn {
584                schema: Some("s"),
585                table: "t",
586                column: "c",
587                data_type: &long,
588            },
589        )
590        .is_none()
591    {
592        findings.push(Finding {
593            severity: Severity::Note,
594            what: "this dialect says its engine widens a string's declared length in place, and                    writes no statement for the change"
595                .to_owned(),
596            fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this                   engine's spelling of it. Without it Razeri tells an operator the widening                   costs nothing and then hands them nothing to run, which reads as Razeri not                   having finished rather than as a statement they own."
597                .to_owned(),
598        });
599    }
600
601    if answers.nested_field_addition_is_lossless
602        && DdlRenderer::render_column_evolution(
603            dialect,
604            &ColumnEvolution::AddNestedField {
605                schema: Some("s"),
606                table: "t",
607                column: "c",
608                field: "f",
609                data_type: &long,
610            },
611        )
612        .is_none()
613    {
614        findings.push(Finding {
615            severity: Severity::Note,
616            what: "this dialect says its engine keeps every value when a record gains a field,                    and writes no statement for the change"
617                .to_owned(),
618            fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some                   engines name the new field; some restate the whole column's type, which is                   why the change carries both the field's path and the column's type with the                   field in it."
619                .to_owned(),
620        });
621    }
622}
623
624/// **What a stated `MERGE` restriction changes about the SQL Razeri writes for
625/// this engine**, said back to the author while the claim is still cheap to
626/// withdraw.
627///
628/// A restriction left at its default costs nothing and is graded nowhere: the
629/// kit cannot tell a measured *"my engine is fine with this"* from a question
630/// nobody asked, and inventing a finding out of that silence would fire on
631/// every dialect. What it can do is report the restriction a dialect does
632/// state, because each one makes Razeri emit something other than the
633/// statement it emits everywhere else.
634fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
635    if restrictions.slice_pruning_needs_literal {
636        findings.push(Finding {
637            severity: Severity::Note,
638            what: "this dialect says its planner reads one slice of a partitioned table only \
639                   from a literal, so a load scoped to one day cannot simply bind the day"
640                .to_owned(),
641            fix: "Razeri will put the day into the statement text through this engine's \
642                  dynamic-execution form rather than binding it to the load procedure's \
643                  parameter. Confirm that is what your engine wants: the statement is composed \
644                  once per call and its text differs per day, which some planners cache and \
645                  some do not. If a bound parameter does prune here, drop the restriction and \
646                  Razeri binds it like everywhere else."
647                .to_owned(),
648        });
649    }
650}
651
652/// The one override nothing tells an author about, and every formula in every
653/// design goes through it.
654fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
655    let formula = SqlRenderer::to_formula_renderer(dialect);
656    let dialect_name = SqlRenderer::dialect_name(dialect);
657    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
658        findings.push(Finding {
659            severity: Severity::Blocking,
660            what: "`to_formula_renderer` was not overridden, so every formula in every design \
661                   renders through Razeri's ANSI defaults instead of this dialect"
662                .to_owned(),
663            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
664                  formula this engine sees is silently somebody else's."
665                .to_owned(),
666        });
667    }
668}
669
670/// **The three routine grammars a dialect states**, and what each answer owes.
671///
672/// A grammar Razeri carries needs nothing further. A refusal owes words a
673/// reader can act on. `Custom` owes the render method it promised — see
674/// [`check_custom_grammar`].
675fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
676    match SqlRenderer::procedure_form(dialect) {
677        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
678        // annotated form IS its output. Every other dialect that answers it has
679        // simply not said.
680        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
681        ProcedureForm::Agnostic => findings.push(Finding {
682            severity: Severity::Unanswered,
683            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
684                   annotated form — which no engine parses"
685                .to_owned(),
686            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
687                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
688                  offers instead."
689                .to_owned(),
690        }),
691        ProcedureForm::Unsupported(orientation) => {
692            check_refusal_copy("the stored-procedure refusal", orientation, findings);
693        }
694        ProcedureForm::Custom(grammar) => check_custom_grammar(
695            CustomGrammar {
696                kind: RoutineKind::Procedure,
697                grammar,
698                form_method: "procedure_form",
699                render_method: "render_procedure",
700                noun: "procedures",
701            },
702            |spec| SqlRenderer::render_procedure(dialect, spec),
703            findings,
704        ),
705        _ => {}
706    }
707
708    match SqlRenderer::scalar_function_form(dialect) {
709        ScalarFunctionForm::Unsupported(orientation) => {
710            check_refusal_copy("the scalar-function refusal", orientation, findings);
711        }
712        ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
713            CustomGrammar {
714                kind: RoutineKind::ScalarFunction,
715                grammar,
716                form_method: "scalar_function_form",
717                render_method: "render_scalar_function",
718                noun: "scalar functions",
719            },
720            |spec| SqlRenderer::render_scalar_function(dialect, spec),
721            findings,
722        ),
723        _ => {}
724    }
725
726    match SqlRenderer::table_function_form(dialect) {
727        TableFunctionForm::Unsupported(orientation) => {
728            check_refusal_copy("the table-function refusal", orientation, findings);
729        }
730        TableFunctionForm::Custom(grammar) => check_custom_grammar(
731            CustomGrammar {
732                kind: RoutineKind::TableFunction,
733                grammar,
734                form_method: "table_function_form",
735                render_method: "render_table_function",
736                noun: "table functions",
737            },
738            |spec| SqlRenderer::render_table_function(dialect, spec),
739            findings,
740        ),
741        _ => {}
742    }
743
744    check_parameter_type_spelling(dialect, findings);
745}
746
747/// **A parameter's type is not always spelled the way a column's is**, and this
748/// asks whether the dialect has looked.
749///
750/// Oracle is the measured case: a column there takes `NUMBER(19, 0)` and
751/// `VARCHAR2(100)`, and the same types in a parameter list are a compile error
752/// — the bare `NUMBER` and `VARCHAR2` are what a parameter clause takes, with
753/// the precision belonging on the local declaration instead. A dialect that has
754/// not said otherwise gets the column spelling in its parameter lists, which is
755/// right for most engines and a routine that will not compile on one like that.
756///
757/// It is a note and not a failure, because for most engines the two spellings
758/// genuinely are the same and there is nothing to say. And it is asked only of a
759/// dialect spelling a grammar of its own, because that is the population where
760/// nobody has measured the answer yet.
761fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
762    let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
763        || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
764        || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
765    if !spells_its_own {
766        return;
767    }
768    // A type that carries a length is the only kind the two spellings can
769    // disagree about, so it is the only kind worth asking with.
770    let qualified = DataType::varchar(100);
771    let column = SqlRenderer::render_data_type(dialect, &qualified);
772    let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
773    if column == parameter && column.contains('(') {
774        findings.push(Finding {
775            severity: Severity::Note,
776            what: format!(
777                "a parameter of this routine's would be declared `{parameter}`, the same as a                  column of that type"
778            ),
779            fix: "that is right for most engines, so this is only worth a look. If yours refuses                   a length or a precision in a parameter list — Oracle does, and answers                   PLS-00103 at the opening parenthesis — override                   `SqlRenderer::render_parameter_data_type` with the bare spelling. Local                   declarations keep the length either way."
780                .to_owned(),
781        });
782    }
783}
784
785/// One routine kind whose grammar a dialect said it spells itself.
786#[derive(Clone, Copy)]
787struct CustomGrammar {
788    /// Which routine kind the claim is about.
789    kind: RoutineKind,
790    /// The grammar's own name, as the dialect wrote it.
791    grammar: &'static str,
792    /// The method that carried the claim.
793    form_method: &'static str,
794    /// The method that owes the rendering.
795    render_method: &'static str,
796    /// What to call the routines in a sentence.
797    noun: &'static str,
798}
799
800/// **A dialect that says it spells its own grammar has to spell it.**
801///
802/// `Custom` moves the whole rendering to the dialect: Razeri substitutes no
803/// near-neighbour grammar and refuses nothing, because both of those say
804/// something false — one about the engine's syntax, the other about whether the
805/// engine has the routine at all. So the claim is only worth anything with the
806/// method behind it, and a dialect that made the claim and left the method
807/// alone ships a build that stops on the routine.
808///
809/// The check is the question itself: hand the render method the shape it will
810/// receive and see whether anything comes back.
811///
812/// Parameters:
813/// - `claim`: which kind, which grammar, and the two method names to name;
814/// - `render`: the dialect's render method for that kind;
815/// - `findings`: where the report is collected.
816fn check_custom_grammar(
817    claim: CustomGrammar,
818    render: impl Fn(&RoutineSpec) -> Option<String>,
819    findings: &mut Vec<Finding>,
820) {
821    let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
822    // The grammar's name is a name, not a refusal: it is read in a report and
823    // in a comment, so it has to say something and does not have to say much.
824    if grammar.trim().is_empty() {
825        findings.push(Finding {
826            severity: Severity::Blocking,
827            what: format!("`{form_method}` says Custom, and names no grammar"),
828            fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
829                  of a report, and of the comment Razeri writes where a routine could not be \
830                  rendered, is told this engine speaks."
831                .to_owned(),
832        });
833    }
834
835    let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
836    probe.or_replace = true;
837    if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
838        findings.push(Finding {
839            severity: Severity::Blocking,
840            what: format!(
841                "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
842                 itself — and `{render_method}` returns nothing"
843            ),
844            fix: format!(
845                "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
846                 composed — name, parameters with their types already in your spelling, body, \
847                 comment — and returns the whole CREATE statement your engine parses. Razeri \
848                 emits what you return and writes nothing of its own: a near-neighbour grammar \
849                 fails on deploy, and a refusal would say this engine has no {noun} when it \
850                 has them in a grammar of its own."
851            ),
852        });
853    }
854}
855
856/// A refusal that does not say what to do instead is a wall.
857fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
858    if copy.trim().is_empty() {
859        findings.push(Finding {
860            severity: Severity::Blocking,
861            what: format!("{subject} has no words in it"),
862            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
863                  next thing to try, and it has to be about their engine."
864                .to_owned(),
865        });
866        return;
867    }
868    if copy.split_whitespace().count() < 5 {
869        findings.push(Finding {
870            severity: Severity::Note,
871            what: format!("{subject} is very short"),
872            fix: "a refusal earns its place by naming the alternative — what this engine has \
873                  instead of the thing it cannot do."
874                .to_owned(),
875        });
876    }
877}
878
879/// Fourteen facts are declared twice and must agree.
880fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
881    let caps: &dyn DialectCapabilities = dialect;
882    let ren: &dyn SqlRenderer = dialect;
883    let ddl: &dyn DdlRenderer = dialect;
884    let mut disagreements: Vec<&'static str> = Vec::new();
885
886    macro_rules! pair {
887        ($m:ident, $renderer_value:expr) => {
888            if caps.$m() != $renderer_value {
889                disagreements.push(stringify!($m));
890            }
891        };
892    }
893    pair!(supports_on_conflict, ren.supports_on_conflict());
894    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
895    pair!(supports_update_from, ren.supports_update_from());
896    pair!(supports_named_windows, ren.supports_named_windows());
897    pair!(supports_compound_assignment, ren.supports_compound_assignment());
898    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
899    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
900    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
901    pair!(uses_alter_table_update, ren.uses_alter_table_update());
902    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
903    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
904    pair!(supports_sync_drop, ddl.supports_sync_drop());
905    pair!(supports_force_drop, ddl.supports_force_drop());
906
907    // **The fourteenth, whose two sides are different types.** The capability
908    // is a boolean; the renderer answer is a form that either renders or
909    // carries a refusal. Both are read — the build door reads the form, and
910    // registry-walking rosters read the capability — so a dialect claiming a
911    // table function on one side and refusing on the other has an engine that
912    // both does and does not have one. `SQLite` was exactly that on the day
913    // this check was written: `false` since the capability existed, and a
914    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
915    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
916        disagreements.push("supports_table_functions");
917    }
918
919    if !disagreements.is_empty() {
920        findings.push(Finding {
921            severity: Severity::Blocking,
922            what: format!(
923                "this dialect declares {} fact(s) two ways and the two disagree: {}",
924                disagreements.len(),
925                disagreements.join(", ")
926            ),
927            fix: "each of these is declared once on the capability trait and once in the \
928                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
929                  and move the other one to match."
930                .to_owned(),
931        });
932    }
933}
934
935/// Every type a design can declare, which is the set the required methods owe
936/// an answer for.
937///
938/// The last four are the ones whose spelling actually varies between engines:
939/// an array, a map, a struct, and an array of structs. A dialect that answers
940/// only for scalars is a dialect that has not been graded on the types a
941/// nested source table is made of.
942fn declarable_types() -> Vec<DataType> {
943    vec![
944        DataType::tinyint(),
945        DataType::smallint(),
946        DataType::int(),
947        DataType::bigint(),
948        DataType::double(),
949        DataType::boolean(),
950        DataType::text(),
951        DataType::varchar(200),
952        DataType::uuid(),
953        DataType::json(),
954        DataType::date(),
955        DataType::timestamp(),
956        DataType::timestamp_tz(),
957        DataType::decimal(18, 2),
958        DataType::Array(ArrayDataType::new(DataType::varchar(100))),
959        DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
960        DataType::Struct(StructDataType::new(vec![
961            StructField::new("street", DataType::varchar(200)),
962            StructField::new("zip", DataType::varchar(10)),
963        ])),
964        DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
965            StructField::new("key", DataType::varchar(100)),
966            StructField::new("value", DataType::varchar(1000)),
967        ])))),
968    ]
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use crate::dialect::DialectInfo;
975    use razeri_plugin_contract::{
976        Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
977        ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
978        TaskRenderer, TextCast,
979    };
980    use std::marker::PhantomData;
981
982    // ── The broken fixture ────────────────────────────────────────────
983    //
984    // A plugin that compiles, registers nothing it should, and answers every
985    // question with somebody else's default. This is what the kit exists to
986    // catch, and it is deliberately the plugin an author gets by implementing
987    // only the four required methods and stopping — which the guide, until
988    // now, told them was enough.
989
990    struct BrokenPlugin;
991
992    impl SqlRenderer for BrokenPlugin {
993        fn render_data_type(&self, _dt: &DataType) -> String {
994            String::new()
995        }
996        fn quote_identifier(&self, name: &str) -> String {
997            name.to_owned()
998        }
999        fn render_cast_type(&self, _dt: &DataType) -> String {
1000            String::new()
1001        }
1002        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1003            name.to_owned()
1004        }
1005        fn render_current_timestamp(&self) -> String {
1006            String::new()
1007        }
1008        fn dialect_name(&self) -> &'static str {
1009            "brokendb"
1010        }
1011        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
1012        // and one fact declared twice, two ways.
1013        fn supports_on_conflict(&self) -> bool {
1014            true
1015        }
1016    }
1017    impl ExpressionRenderer for BrokenPlugin {}
1018    impl DdlRenderer for BrokenPlugin {}
1019    impl DmlRenderer for BrokenPlugin {}
1020    impl ProcedureRenderer for BrokenPlugin {}
1021    impl FormulaRenderer for BrokenPlugin {}
1022    impl TaskRenderer for BrokenPlugin {}
1023    impl ReplicationRenderer for BrokenPlugin {}
1024    impl StorageRenderer for BrokenPlugin {}
1025    impl DialectCapabilities for BrokenPlugin {
1026        fn supports_on_conflict(&self) -> bool {
1027            false
1028        }
1029    }
1030    impl PlatformDialect for BrokenPlugin {
1031        fn info(&self) -> DialectInfo {
1032            DialectInfo {
1033                name: "brokendb",
1034                display_name: "Broken DB",
1035                aliases: &[],
1036                dialect_family: DialectFamily::Custom(""),
1037                family: "Broken",
1038                is_cloud_native: false,
1039                is_olap: false,
1040                is_postgres_compatible: false,
1041                versions: &[],
1042                deploy_script: None,
1043            }
1044        }
1045    }
1046
1047    // ── The answered fixture, under two names ─────────────────────────
1048    //
1049    // The same plugin with every question answered, carrying the name it
1050    // registers under as a type parameter. Neither name is one Razeri knows a
1051    // spelling for; the difference is that one of them is in the registry and
1052    // the other is in nothing at all. Those are two different facts about a
1053    // dialect, and they read as two different reports.
1054
1055    trait FixtureName: Send + Sync + 'static {
1056        /// The registry name this fixture answers to.
1057        const NAME: &'static str;
1058        /// How this fixture's engine joins the parts of a composite key. Most
1059        /// of them take the inherited variadic `CONCAT`; the one that does not
1060        /// is the point of it being a knob.
1061        const CONCAT: Concat = Concat::Function("CONCAT");
1062    }
1063
1064    /// Answered, and nothing linked it — what an author sees running the report
1065    /// from a crate whose registration nobody calls.
1066    struct Unlinked;
1067    impl FixtureName for Unlinked {
1068        const NAME: &'static str = "healthydb";
1069    }
1070
1071    /// Answered and registered, under a name Razeri ships no spelling for —
1072    /// the shape of every dialect written outside Razeri's own tree.
1073    struct Outside;
1074    impl FixtureName for Outside {
1075        const NAME: &'static str = "outsidedb";
1076    }
1077
1078    /// Answered, and joining composite keys with a `CONCAT` that takes exactly
1079    /// two arguments — the shape of Db2 and of older Oracle releases. Nothing
1080    /// about the join is an obligation, so the report on it must read exactly
1081    /// like the report on a variadic engine.
1082    struct BinaryConcat;
1083    impl FixtureName for BinaryConcat {
1084        const NAME: &'static str = "binaryconcatdb";
1085        const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1086    }
1087
1088    struct Answered<N: FixtureName>(PhantomData<N>);
1089
1090    impl<N: FixtureName> Answered<N> {
1091        const fn new() -> Self {
1092            Self(PhantomData)
1093        }
1094    }
1095
1096    type HealthyPlugin = Answered<Unlinked>;
1097    type OutsidePlugin = Answered<Outside>;
1098
1099    inventory::submit! {
1100        crate::builder::DialectRegistration::new(
1101            Outside::NAME,
1102            || std::sync::Arc::new(OutsidePlugin::new()),
1103        )
1104    }
1105
1106    impl<N: FixtureName> SqlRenderer for Answered<N> {
1107        fn render_data_type(&self, dt: &DataType) -> String {
1108            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1109        }
1110        fn quote_identifier(&self, name: &str) -> String {
1111            format!("\"{name}\"")
1112        }
1113        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1114            schema.map_or_else(
1115                || self.quote_identifier(name),
1116                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1117            )
1118        }
1119        fn render_current_timestamp(&self) -> String {
1120            "NOW()".to_owned()
1121        }
1122        fn dialect_name(&self) -> &'static str {
1123            N::NAME
1124        }
1125        fn sid_spelling(&self) -> SidSpelling {
1126            SidSpelling {
1127                platform: N::NAME,
1128                hash: ("answered_xxhash64(", ")"),
1129                cast_type: TextCast::Direct("STRING"),
1130                concat: N::CONCAT,
1131                through_helper: false,
1132                ..razeri_plugin_contract::ANSI_SID_SPELLING
1133            }
1134        }
1135        fn procedure_form(&self) -> ProcedureForm {
1136            ProcedureForm::Unsupported(
1137                "This engine has no stored procedures; write the load as a scheduled query \
1138                 instead.",
1139            )
1140        }
1141        fn scalar_function_form(&self) -> ScalarFunctionForm {
1142            ScalarFunctionForm::PostgreSql
1143        }
1144        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1145            Box::new(Self::new())
1146        }
1147    }
1148    impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1149    impl<N: FixtureName> DdlRenderer for Answered<N> {}
1150    impl<N: FixtureName> DmlRenderer for Answered<N> {}
1151    impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1152    impl<N: FixtureName> FormulaRenderer for Answered<N> {}
1153    impl<N: FixtureName> TaskRenderer for Answered<N> {}
1154    impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1155    impl<N: FixtureName> StorageRenderer for Answered<N> {}
1156    impl<N: FixtureName> DialectCapabilities for Answered<N> {
1157        fn merge_shape(&self) -> MergeShape {
1158            MergeShape::Merge(MergeRestrictions::none())
1159        }
1160    }
1161    impl<N: FixtureName> PlatformDialect for Answered<N> {
1162        fn info(&self) -> DialectInfo {
1163            DialectInfo {
1164                name: N::NAME,
1165                display_name: "An Answered Engine",
1166                // It answers to its own name and nothing else, which is the
1167                // shape of a plugin that declares no extra spellings.
1168                aliases: &[],
1169                dialect_family: DialectFamily::Custom(N::NAME),
1170                family: "Answered",
1171                is_cloud_native: false,
1172                is_olap: false,
1173                is_postgres_compatible: false,
1174                versions: &[],
1175                deploy_script: None,
1176            }
1177        }
1178    }
1179
1180    // ── The twin: a dialect that claims a name another platform answers to ──
1181    //
1182    // Answered in every other respect, so the only thing its report can be
1183    // about is the spelling it took.
1184
1185    struct Twin;
1186
1187    impl SqlRenderer for Twin {
1188        fn render_data_type(&self, dt: &DataType) -> String {
1189            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1190        }
1191        fn quote_identifier(&self, name: &str) -> String {
1192            format!("\"{name}\"")
1193        }
1194        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1195            self.quote_identifier(name)
1196        }
1197        fn render_current_timestamp(&self) -> String {
1198            "NOW()".to_owned()
1199        }
1200    }
1201    impl ExpressionRenderer for Twin {}
1202    impl DdlRenderer for Twin {}
1203    impl DmlRenderer for Twin {}
1204    impl ProcedureRenderer for Twin {}
1205    impl FormulaRenderer for Twin {}
1206    impl TaskRenderer for Twin {}
1207    impl ReplicationRenderer for Twin {}
1208    impl StorageRenderer for Twin {}
1209    impl DialectCapabilities for Twin {}
1210    impl PlatformDialect for Twin {
1211        fn info(&self) -> DialectInfo {
1212            DialectInfo {
1213                name: "twindb",
1214                display_name: "Twin DB",
1215                // The name the answered fixture already answers to.
1216                aliases: &["healthydb"],
1217                dialect_family: DialectFamily::Custom("twindb"),
1218                family: "Twin",
1219                is_cloud_native: false,
1220                is_olap: false,
1221                is_postgres_compatible: false,
1222                versions: &[],
1223                deploy_script: None,
1224            }
1225        }
1226    }
1227
1228    /// Every finding whose `what` line mentions one of these subjects.
1229    fn mentions(report: &Report, needle: &str) -> bool {
1230        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1231    }
1232
1233    /// **The kit's own red.** A plugin that implements the four required
1234    /// methods and stops is exactly what the guide used to produce, and every
1235    /// one of these findings is a defect a shipped dialect actually had.
1236    #[test]
1237    fn the_kit_catches_a_deliberately_broken_plugin() {
1238        let report = check(&BrokenPlugin);
1239        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1240
1241        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1242        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1243        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1244        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1245        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1246        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1247        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1248        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1249        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1250        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1251        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1252
1253        assert!(
1254            report.count(Severity::Blocking) >= 8,
1255            "the broken plugin owes more than {} blocking fixes:\n{report}",
1256            report.count(Severity::Blocking)
1257        );
1258    }
1259
1260    /// **The kit does not cry wolf.** The same plugin with its questions
1261    /// answered reports only what is actually true of it: it is not
1262    /// registered in this test binary, and nothing else.
1263    #[test]
1264    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1265        let report = check(&HealthyPlugin::new());
1266        let unregistered: Vec<&Finding> =
1267            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1268        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1269        assert_eq!(
1270            report.findings.len(),
1271            1,
1272            "a plugin that answered every question owes nothing else:\n{report}"
1273        );
1274    }
1275
1276    /// **A join written for a two-argument `CONCAT` is an answer, not a
1277    /// shortfall.** The report on a dialect that states it reads exactly like
1278    /// the report on a variadic one, and the key it renders nests pairwise
1279    /// instead of widening a call the engine does not have.
1280    #[test]
1281    fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1282        let binary = Answered::<BinaryConcat>::new();
1283        let report = check(&binary);
1284        assert_eq!(
1285            report.findings.len(),
1286            check(&HealthyPlugin::new()).findings.len(),
1287            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1288        );
1289        assert!(
1290            !report.to_string().contains("joins"),
1291            "nothing about the join is outstanding:\n{report}"
1292        );
1293
1294        let key = razeri_plugin_contract::sid::sid_expression(
1295            SqlRenderer::sid_spelling(&binary),
1296            None,
1297            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1298        );
1299        assert!(
1300            key.contains("CONCAT(CONCAT("),
1301            "a two-argument engine gets one call per pair:\n{key}"
1302        );
1303    }
1304
1305    /// **A stated `MERGE` restriction is reported; a default one is not.**
1306    ///
1307    /// The asymmetry is the point. A dialect that says its planner needs a
1308    /// literal slice gets Razeri's plan for that answer back, while the engine
1309    /// it is describing is still in front of the author. A dialect that has
1310    /// said nothing gets nothing, because the kit cannot tell a measured
1311    /// *"a parameter is fine here"* from a question never asked, and a finding
1312    /// made out of that silence would fire on every dialect alike.
1313    #[test]
1314    fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1315        let mut quiet = Vec::new();
1316        check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1317        assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1318
1319        let mut stated = Vec::new();
1320        check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1321        assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1322        assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1323        assert!(
1324            stated[0].what.contains("literal"),
1325            "the finding names the restriction:\n{}",
1326            stated[0].what
1327        );
1328        assert!(
1329            stated[0].fix.contains("dynamic-execution"),
1330            "and says what Razeri does with the answer:\n{}",
1331            stated[0].fix
1332        );
1333
1334        // The other restriction travels on the same struct and must not drag
1335        // the new finding along with it.
1336        let mut two_clauses = Vec::new();
1337        check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1338        assert!(two_clauses.is_empty(), "one restriction is not the other");
1339    }
1340
1341    /// **A dialect written outside Razeri reaches a clean report.** It is
1342    /// reached the way every out-of-tree dialect is reached — it registered, and
1343    /// the resolver reads the loaded dialects' own declared spellings — so there
1344    /// is nothing for Razeri to be missing it from and nothing for the report to
1345    /// ask of its author.
1346    #[test]
1347    fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1348        assert!(
1349            crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1350            "the fixture registers through the registry, which is the whole path being graded"
1351        );
1352        // The precondition that used to read "no row in Razeri's table": no
1353        // other loaded platform declares this name, so the fixture is reached
1354        // only because it registered and said so itself.
1355        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1356        assert!(
1357            registry
1358                .list()
1359                .iter()
1360                .filter(|info| info.name != Outside::NAME)
1361                .all(|info| !info.answers_to(Outside::NAME)),
1362            "the fixture's name must be one no other platform claims"
1363        );
1364
1365        let report = check(&OutsidePlugin::new());
1366        assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1367        assert!(report.is_clean(), "and the report says so:\n{report}");
1368        assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1369        assert!(
1370            report.to_string().contains("nothing to fix"),
1371            "a clean dialect is told it is clean:\n{report}"
1372        );
1373    }
1374
1375    /// **A platform cannot be reachable under an alias but not under its own
1376    /// name.** That used to be a judgement about a hand-written table, which
1377    /// could spell a platform `mynew_db` and never `mynewdb`. A dialect now
1378    /// declares its own spellings and its own name leads them, so the defect is
1379    /// unrepresentable rather than caught — and this is the assertion that keeps
1380    /// it that way.
1381    #[test]
1382    fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1383        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1384        for info in registry.list() {
1385            assert_eq!(
1386                info.spellings().first().copied(),
1387                Some(info.name),
1388                "a platform's own name leads its spellings"
1389            );
1390            assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1391        }
1392    }
1393
1394    /// **A name another platform already answers to is a collision, not a
1395    /// registration.** A dialect claiming a spelling Healthy DB answers to is
1396    /// named as the collision it is — with both platforms named, and a fix its
1397    /// author can actually perform — rather than being reported as an
1398    /// unregistered platform, which is a different problem with a different fix.
1399    #[test]
1400    fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1401        let mut registry = crate::dialect::PlatformRegistry::new();
1402        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1403        registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1404
1405        let report = check_with_registry(&Twin, &registry);
1406        let collision = report
1407            .findings
1408            .iter()
1409            .find(|f| f.what.contains("claimed by more than one loaded platform"))
1410            .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1411        assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1412        assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1413        assert!(collision.what.contains("twindb"), "{collision:?}");
1414        assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1415        assert!(
1416            !mentions(&report, "does not resolve"),
1417            "a collision is not an unregistered platform:\n{report}"
1418        );
1419    }
1420
1421    /// A platform reached under its own name owes nothing, whatever the case or
1422    /// the surrounding whitespace, and a name nothing has ever heard of is
1423    /// simply unknown rather than a finding against this dialect.
1424    #[test]
1425    fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1426        let mut registry = crate::dialect::PlatformRegistry::new();
1427        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1428
1429        let mut findings = Vec::new();
1430        check_registration(&HealthyPlugin::new().info(), &registry, &mut findings);
1431        assert!(findings.is_empty(), "{findings:?}");
1432
1433        for spelling in ["healthydb", "HealthyDB", "  healthydb  "] {
1434            assert!(
1435                crate::resolve::resolve_dialect_by_name_with_registry(spelling, &registry).is_ok(),
1436                "'{spelling}' is the same platform"
1437            );
1438        }
1439        assert!(
1440            crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", &registry).is_err()
1441        );
1442    }
1443
1444    /// **A dialect the host loaded at run time can be graded at all.** [`check`]
1445    /// asks the dialects linked into this program, which one loaded from a
1446    /// shared library is not among; [`check_with_registry`] asks the registry it
1447    /// was loaded into, and the registration judgement is answered from there.
1448    #[test]
1449    fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1450        let mut registry = crate::dialect::PlatformRegistry::new();
1451        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1452
1453        let report = check_with_registry(&HealthyPlugin::new(), &registry);
1454        assert!(
1455            report.is_clean(),
1456            "a loaded dialect that answered everything owes nothing:\n{report}"
1457        );
1458    }
1459
1460    /// **Every fix an outside author can read is a fix in their own crate.** A
1461    /// report that names a file inside Razeri is telling an author to edit a
1462    /// tree they do not have, and the fix cannot be performed at all.
1463    #[test]
1464    fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1465        let mut collided = crate::dialect::PlatformRegistry::new();
1466        collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1467        collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1468
1469        let mut fixes: Vec<String> = Vec::new();
1470        for report in [
1471            check(&BrokenPlugin),
1472            check(&HealthyPlugin::new()),
1473            check(&OutsidePlugin::new()),
1474            // The collision and the unregistered-name judgements, which are the
1475            // two registration findings an author can actually reach.
1476            check_with_registry(&Twin, &collided),
1477            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1478        ] {
1479            fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1480        }
1481        assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1482        for fix in &fixes {
1483            for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1484                assert!(
1485                    !fix.contains(path),
1486                    "a fix line names `{path}`, which is not in the author's crate:\n  {fix}"
1487                );
1488            }
1489        }
1490    }
1491
1492    /// **A dialect that claims its own grammar and does not spell it is
1493    /// blocking**, and the finding names the method it owes.
1494    ///
1495    /// `Custom` is a promise that the dialect renders the routine itself, and
1496    /// Razeri keeps its side by writing nothing: no near-neighbour grammar,
1497    /// which fails on deploy, and no refusal, which would tell an author their
1498    /// engine has no routines when it has a whole language. A promise with
1499    /// nothing behind it is therefore a build that stops, and it has to be
1500    /// findable before the deploy rather than during it.
1501    #[test]
1502    fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1503        for (kind, form_method, render_method) in [
1504            (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1505            (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1506            (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1507        ] {
1508            let mut promised = Vec::new();
1509            check_custom_grammar(
1510                CustomGrammar {
1511                    kind,
1512                    grammar: "PL/SQL",
1513                    form_method,
1514                    render_method,
1515                    noun: "routines",
1516                },
1517                |_| None,
1518                &mut promised,
1519            );
1520            assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1521            assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1522            assert!(promised[0].what.contains(form_method), "{promised:?}");
1523            assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1524            assert!(promised[0].fix.contains(render_method), "{promised:?}");
1525
1526            // The same claim, kept: nothing to report.
1527            let mut kept = Vec::new();
1528            check_custom_grammar(
1529                CustomGrammar {
1530                    kind,
1531                    grammar: "PL/SQL",
1532                    form_method,
1533                    render_method,
1534                    noun: "routines",
1535                },
1536                |spec| Some(format!("CREATE {} …", spec.name)),
1537                &mut kept,
1538            );
1539            assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1540        }
1541    }
1542
1543    /// **A render method that answers with nothing at all is the same defect.**
1544    ///
1545    /// An override that returns an empty string is a routine razeri would emit
1546    /// as blank space in the middle of a schema, which is worse than an error:
1547    /// nothing in the deploy says anything is missing.
1548    #[test]
1549    fn a_custom_render_method_that_returns_nothing_is_blocking() {
1550        let mut findings = Vec::new();
1551        check_custom_grammar(
1552            CustomGrammar {
1553                kind: RoutineKind::Procedure,
1554                grammar: "PL/SQL",
1555                form_method: "procedure_form",
1556                render_method: "render_procedure",
1557                noun: "procedures",
1558            },
1559            |_| Some("   \n".to_owned()),
1560            &mut findings,
1561        );
1562        assert_eq!(findings.len(), 1, "{findings:?}");
1563        assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1564    }
1565
1566    /// **A `Custom` grammar with no name gives a reader nothing to read.**
1567    #[test]
1568    fn a_custom_grammar_with_no_name_is_blocking() {
1569        let mut findings = Vec::new();
1570        check_custom_grammar(
1571            CustomGrammar {
1572                kind: RoutineKind::ScalarFunction,
1573                grammar: "  ",
1574                form_method: "scalar_function_form",
1575                render_method: "render_scalar_function",
1576                noun: "scalar functions",
1577            },
1578            |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1579            &mut findings,
1580        );
1581        assert_eq!(findings.len(), 1, "{findings:?}");
1582        assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1583    }
1584
1585    /// **A stated fact that is empty, and a stated fact that ignores its
1586    /// argument.**
1587    ///
1588    /// Both answers deploy without an error and are wrong in the data: an empty
1589    /// helper body keys every row to nothing, and a file relation that drops
1590    /// its path reads one file for every entity a design binds. Neither is
1591    /// visible anywhere else, which is why the kit grades them.
1592    #[test]
1593    fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1594        struct Overstated;
1595        impl SqlRenderer for Overstated {
1596            fn render_data_type(&self, dt: &DataType) -> String {
1597                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1598            }
1599            fn quote_identifier(&self, name: &str) -> String {
1600                format!("\"{name}\"")
1601            }
1602            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1603                name.to_owned()
1604            }
1605            fn render_current_timestamp(&self) -> String {
1606                "CURRENT_TIMESTAMP".to_owned()
1607            }
1608            fn dialect_name(&self) -> &'static str {
1609                "overstateddb"
1610            }
1611            fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1612                Some(razeri_plugin_contract::SidHelperBody {
1613                    language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1614                    body: "   \n  ",
1615                })
1616            }
1617            fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1618                Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1619            }
1620        }
1621        impl ExpressionRenderer for Overstated {}
1622        impl DdlRenderer for Overstated {}
1623        impl DmlRenderer for Overstated {}
1624        impl ProcedureRenderer for Overstated {}
1625        impl FormulaRenderer for Overstated {}
1626        impl TaskRenderer for Overstated {}
1627        impl ReplicationRenderer for Overstated {}
1628        impl StorageRenderer for Overstated {}
1629        impl DialectCapabilities for Overstated {}
1630        impl PlatformDialect for Overstated {
1631            fn info(&self) -> DialectInfo {
1632                DialectInfo {
1633                    name: "overstateddb",
1634                    display_name: "Overstated DB",
1635                    aliases: &[],
1636                    dialect_family: DialectFamily::Custom(""),
1637                    family: "Overstated",
1638                    is_cloud_native: false,
1639                    is_olap: false,
1640                    is_postgres_compatible: false,
1641                    versions: &[],
1642                    deploy_script: None,
1643                }
1644            }
1645        }
1646
1647        let report = check(&Overstated);
1648        for (fragment, why) in [
1649            ("supplies an empty one", "a blank helper body"),
1650            ("does not carry the path", "a file relation that drops its path"),
1651        ] {
1652            let found = report
1653                .findings
1654                .iter()
1655                .find(|f| f.what.contains(fragment))
1656                .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1657            assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1658        }
1659
1660        // And silence is not a finding. The fully-answered fixture states
1661        // neither fact — the majority answer — and owes nothing for it.
1662        let quiet = check(&HealthyPlugin::new());
1663        for fragment in ["supplies an empty one", "does not carry the path"] {
1664            assert!(
1665                !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1666                "silence is the default and owes nothing:\n{quiet}"
1667            );
1668        }
1669    }
1670
1671    /// **A stated refusal is a claim, and a claim owes the reader words** — the
1672    /// same grading every other refusal gets, because these words are the only
1673    /// thing an operator finds where the load would have been.
1674    #[test]
1675    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1676        let mut mute = Vec::new();
1677        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1678        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1679        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1680
1681        let silence = check(&BrokenPlugin);
1682        assert!(
1683            silence
1684                .findings
1685                .iter()
1686                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1687            "a dialect that never answered is unanswered, not wrong:\n{silence}"
1688        );
1689        assert!(
1690            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1691            "the fix line names the way to state a measured refusal:\n{silence}"
1692        );
1693    }
1694
1695    /// The report leads with what to fix and says it in words a reader can
1696    /// act on — no identifiers from Razeri's own record-keeping, and a
1697    /// one-line count before the detail.
1698    #[test]
1699    fn the_report_reads_as_a_summary_then_a_fix_list() {
1700        let text = check(&BrokenPlugin).to_string();
1701        let first = text.lines().next().expect("a summary line");
1702        assert!(first.starts_with("brokendb:"), "{first}");
1703        assert!(first.contains("to fix"), "{first}");
1704        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
1705    }
1706}