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            }
1042        }
1043    }
1044
1045    // ── The answered fixture, under two names ─────────────────────────
1046    //
1047    // The same plugin with every question answered, carrying the name it
1048    // registers under as a type parameter. Neither name is one Razeri knows a
1049    // spelling for; the difference is that one of them is in the registry and
1050    // the other is in nothing at all. Those are two different facts about a
1051    // dialect, and they read as two different reports.
1052
1053    trait FixtureName: Send + Sync + 'static {
1054        /// The registry name this fixture answers to.
1055        const NAME: &'static str;
1056        /// How this fixture's engine joins the parts of a composite key. Most
1057        /// of them take the inherited variadic `CONCAT`; the one that does not
1058        /// is the point of it being a knob.
1059        const CONCAT: Concat = Concat::Function("CONCAT");
1060    }
1061
1062    /// Answered, and nothing linked it — what an author sees running the report
1063    /// from a crate whose registration nobody calls.
1064    struct Unlinked;
1065    impl FixtureName for Unlinked {
1066        const NAME: &'static str = "healthydb";
1067    }
1068
1069    /// Answered and registered, under a name Razeri ships no spelling for —
1070    /// the shape of every dialect written outside Razeri's own tree.
1071    struct Outside;
1072    impl FixtureName for Outside {
1073        const NAME: &'static str = "outsidedb";
1074    }
1075
1076    /// Answered, and joining composite keys with a `CONCAT` that takes exactly
1077    /// two arguments — the shape of Db2 and of older Oracle releases. Nothing
1078    /// about the join is an obligation, so the report on it must read exactly
1079    /// like the report on a variadic engine.
1080    struct BinaryConcat;
1081    impl FixtureName for BinaryConcat {
1082        const NAME: &'static str = "binaryconcatdb";
1083        const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1084    }
1085
1086    struct Answered<N: FixtureName>(PhantomData<N>);
1087
1088    impl<N: FixtureName> Answered<N> {
1089        const fn new() -> Self {
1090            Self(PhantomData)
1091        }
1092    }
1093
1094    type HealthyPlugin = Answered<Unlinked>;
1095    type OutsidePlugin = Answered<Outside>;
1096
1097    inventory::submit! {
1098        crate::builder::DialectRegistration::new(
1099            Outside::NAME,
1100            || std::sync::Arc::new(OutsidePlugin::new()),
1101        )
1102    }
1103
1104    impl<N: FixtureName> SqlRenderer for Answered<N> {
1105        fn render_data_type(&self, dt: &DataType) -> String {
1106            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1107        }
1108        fn quote_identifier(&self, name: &str) -> String {
1109            format!("\"{name}\"")
1110        }
1111        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1112            schema.map_or_else(
1113                || self.quote_identifier(name),
1114                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1115            )
1116        }
1117        fn render_current_timestamp(&self) -> String {
1118            "NOW()".to_owned()
1119        }
1120        fn dialect_name(&self) -> &'static str {
1121            N::NAME
1122        }
1123        fn sid_spelling(&self) -> SidSpelling {
1124            SidSpelling {
1125                platform: N::NAME,
1126                hash: ("answered_xxhash64(", ")"),
1127                cast_type: TextCast::Direct("STRING"),
1128                concat: N::CONCAT,
1129                through_helper: false,
1130                ..razeri_plugin_contract::ANSI_SID_SPELLING
1131            }
1132        }
1133        fn procedure_form(&self) -> ProcedureForm {
1134            ProcedureForm::Unsupported(
1135                "This engine has no stored procedures; write the load as a scheduled query \
1136                 instead.",
1137            )
1138        }
1139        fn scalar_function_form(&self) -> ScalarFunctionForm {
1140            ScalarFunctionForm::PostgreSql
1141        }
1142        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1143            Box::new(Self::new())
1144        }
1145    }
1146    impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1147    impl<N: FixtureName> DdlRenderer for Answered<N> {}
1148    impl<N: FixtureName> DmlRenderer for Answered<N> {}
1149    impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1150    impl<N: FixtureName> FormulaRenderer for Answered<N> {}
1151    impl<N: FixtureName> TaskRenderer for Answered<N> {}
1152    impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1153    impl<N: FixtureName> StorageRenderer for Answered<N> {}
1154    impl<N: FixtureName> DialectCapabilities for Answered<N> {
1155        fn merge_shape(&self) -> MergeShape {
1156            MergeShape::Merge(MergeRestrictions::none())
1157        }
1158    }
1159    impl<N: FixtureName> PlatformDialect for Answered<N> {
1160        fn info(&self) -> DialectInfo {
1161            DialectInfo {
1162                name: N::NAME,
1163                display_name: "An Answered Engine",
1164                // It answers to its own name and nothing else, which is the
1165                // shape of a plugin that declares no extra spellings.
1166                aliases: &[],
1167                dialect_family: DialectFamily::Custom(N::NAME),
1168                family: "Answered",
1169                is_cloud_native: false,
1170                is_olap: false,
1171                is_postgres_compatible: false,
1172            }
1173        }
1174    }
1175
1176    // ── The twin: a dialect that claims a name another platform answers to ──
1177    //
1178    // Answered in every other respect, so the only thing its report can be
1179    // about is the spelling it took.
1180
1181    struct Twin;
1182
1183    impl SqlRenderer for Twin {
1184        fn render_data_type(&self, dt: &DataType) -> String {
1185            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1186        }
1187        fn quote_identifier(&self, name: &str) -> String {
1188            format!("\"{name}\"")
1189        }
1190        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1191            self.quote_identifier(name)
1192        }
1193        fn render_current_timestamp(&self) -> String {
1194            "NOW()".to_owned()
1195        }
1196    }
1197    impl ExpressionRenderer for Twin {}
1198    impl DdlRenderer for Twin {}
1199    impl DmlRenderer for Twin {}
1200    impl ProcedureRenderer for Twin {}
1201    impl FormulaRenderer for Twin {}
1202    impl TaskRenderer for Twin {}
1203    impl ReplicationRenderer for Twin {}
1204    impl StorageRenderer for Twin {}
1205    impl DialectCapabilities for Twin {}
1206    impl PlatformDialect for Twin {
1207        fn info(&self) -> DialectInfo {
1208            DialectInfo {
1209                name: "twindb",
1210                display_name: "Twin DB",
1211                // The name the answered fixture already answers to.
1212                aliases: &["healthydb"],
1213                dialect_family: DialectFamily::Custom("twindb"),
1214                family: "Twin",
1215                is_cloud_native: false,
1216                is_olap: false,
1217                is_postgres_compatible: false,
1218            }
1219        }
1220    }
1221
1222    /// Every finding whose `what` line mentions one of these subjects.
1223    fn mentions(report: &Report, needle: &str) -> bool {
1224        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1225    }
1226
1227    /// **The kit's own red.** A plugin that implements the four required
1228    /// methods and stops is exactly what the guide used to produce, and every
1229    /// one of these findings is a defect a shipped dialect actually had.
1230    #[test]
1231    fn the_kit_catches_a_deliberately_broken_plugin() {
1232        let report = check(&BrokenPlugin);
1233        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1234
1235        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1236        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1237        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1238        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1239        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1240        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1241        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1242        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1243        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1244        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1245        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1246
1247        assert!(
1248            report.count(Severity::Blocking) >= 8,
1249            "the broken plugin owes more than {} blocking fixes:\n{report}",
1250            report.count(Severity::Blocking)
1251        );
1252    }
1253
1254    /// **The kit does not cry wolf.** The same plugin with its questions
1255    /// answered reports only what is actually true of it: it is not
1256    /// registered in this test binary, and nothing else.
1257    #[test]
1258    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1259        let report = check(&HealthyPlugin::new());
1260        let unregistered: Vec<&Finding> =
1261            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1262        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1263        assert_eq!(
1264            report.findings.len(),
1265            1,
1266            "a plugin that answered every question owes nothing else:\n{report}"
1267        );
1268    }
1269
1270    /// **A join written for a two-argument `CONCAT` is an answer, not a
1271    /// shortfall.** The report on a dialect that states it reads exactly like
1272    /// the report on a variadic one, and the key it renders nests pairwise
1273    /// instead of widening a call the engine does not have.
1274    #[test]
1275    fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1276        let binary = Answered::<BinaryConcat>::new();
1277        let report = check(&binary);
1278        assert_eq!(
1279            report.findings.len(),
1280            check(&HealthyPlugin::new()).findings.len(),
1281            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1282        );
1283        assert!(
1284            !report.to_string().contains("joins"),
1285            "nothing about the join is outstanding:\n{report}"
1286        );
1287
1288        let key = razeri_plugin_contract::sid::sid_expression(
1289            SqlRenderer::sid_spelling(&binary),
1290            None,
1291            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1292        );
1293        assert!(
1294            key.contains("CONCAT(CONCAT("),
1295            "a two-argument engine gets one call per pair:\n{key}"
1296        );
1297    }
1298
1299    /// **A stated `MERGE` restriction is reported; a default one is not.**
1300    ///
1301    /// The asymmetry is the point. A dialect that says its planner needs a
1302    /// literal slice gets Razeri's plan for that answer back, while the engine
1303    /// it is describing is still in front of the author. A dialect that has
1304    /// said nothing gets nothing, because the kit cannot tell a measured
1305    /// *"a parameter is fine here"* from a question never asked, and a finding
1306    /// made out of that silence would fire on every dialect alike.
1307    #[test]
1308    fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1309        let mut quiet = Vec::new();
1310        check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1311        assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1312
1313        let mut stated = Vec::new();
1314        check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1315        assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1316        assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1317        assert!(
1318            stated[0].what.contains("literal"),
1319            "the finding names the restriction:\n{}",
1320            stated[0].what
1321        );
1322        assert!(
1323            stated[0].fix.contains("dynamic-execution"),
1324            "and says what Razeri does with the answer:\n{}",
1325            stated[0].fix
1326        );
1327
1328        // The other restriction travels on the same struct and must not drag
1329        // the new finding along with it.
1330        let mut two_clauses = Vec::new();
1331        check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1332        assert!(two_clauses.is_empty(), "one restriction is not the other");
1333    }
1334
1335    /// **A dialect written outside Razeri reaches a clean report.** It is
1336    /// reached the way every out-of-tree dialect is reached — it registered, and
1337    /// the resolver reads the loaded dialects' own declared spellings — so there
1338    /// is nothing for Razeri to be missing it from and nothing for the report to
1339    /// ask of its author.
1340    #[test]
1341    fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1342        assert!(
1343            crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1344            "the fixture registers through the registry, which is the whole path being graded"
1345        );
1346        // The precondition that used to read "no row in Razeri's table": no
1347        // other loaded platform declares this name, so the fixture is reached
1348        // only because it registered and said so itself.
1349        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1350        assert!(
1351            registry
1352                .list()
1353                .iter()
1354                .filter(|info| info.name != Outside::NAME)
1355                .all(|info| !info.answers_to(Outside::NAME)),
1356            "the fixture's name must be one no other platform claims"
1357        );
1358
1359        let report = check(&OutsidePlugin::new());
1360        assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1361        assert!(report.is_clean(), "and the report says so:\n{report}");
1362        assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1363        assert!(
1364            report.to_string().contains("nothing to fix"),
1365            "a clean dialect is told it is clean:\n{report}"
1366        );
1367    }
1368
1369    /// **A platform cannot be reachable under an alias but not under its own
1370    /// name.** That used to be a judgement about a hand-written table, which
1371    /// could spell a platform `mynew_db` and never `mynewdb`. A dialect now
1372    /// declares its own spellings and its own name leads them, so the defect is
1373    /// unrepresentable rather than caught — and this is the assertion that keeps
1374    /// it that way.
1375    #[test]
1376    fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1377        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1378        for info in registry.list() {
1379            assert_eq!(
1380                info.spellings().first().copied(),
1381                Some(info.name),
1382                "a platform's own name leads its spellings"
1383            );
1384            assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1385        }
1386    }
1387
1388    /// **A name another platform already answers to is a collision, not a
1389    /// registration.** A dialect claiming a spelling Healthy DB answers to is
1390    /// named as the collision it is — with both platforms named, and a fix its
1391    /// author can actually perform — rather than being reported as an
1392    /// unregistered platform, which is a different problem with a different fix.
1393    #[test]
1394    fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1395        let mut registry = crate::dialect::PlatformRegistry::new();
1396        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1397        registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1398
1399        let report = check_with_registry(&Twin, &registry);
1400        let collision = report
1401            .findings
1402            .iter()
1403            .find(|f| f.what.contains("claimed by more than one loaded platform"))
1404            .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1405        assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1406        assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1407        assert!(collision.what.contains("twindb"), "{collision:?}");
1408        assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1409        assert!(
1410            !mentions(&report, "does not resolve"),
1411            "a collision is not an unregistered platform:\n{report}"
1412        );
1413    }
1414
1415    /// A platform reached under its own name owes nothing, whatever the case or
1416    /// the surrounding whitespace, and a name nothing has ever heard of is
1417    /// simply unknown rather than a finding against this dialect.
1418    #[test]
1419    fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1420        let mut registry = crate::dialect::PlatformRegistry::new();
1421        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1422
1423        let mut findings = Vec::new();
1424        check_registration(&HealthyPlugin::new().info(), &registry, &mut findings);
1425        assert!(findings.is_empty(), "{findings:?}");
1426
1427        for spelling in ["healthydb", "HealthyDB", "  healthydb  "] {
1428            assert!(
1429                crate::resolve::resolve_dialect_by_name_with_registry(spelling, &registry).is_ok(),
1430                "'{spelling}' is the same platform"
1431            );
1432        }
1433        assert!(
1434            crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", &registry).is_err()
1435        );
1436    }
1437
1438    /// **A dialect the host loaded at run time can be graded at all.** [`check`]
1439    /// asks the dialects linked into this program, which one loaded from a
1440    /// shared library is not among; [`check_with_registry`] asks the registry it
1441    /// was loaded into, and the registration judgement is answered from there.
1442    #[test]
1443    fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1444        let mut registry = crate::dialect::PlatformRegistry::new();
1445        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1446
1447        let report = check_with_registry(&HealthyPlugin::new(), &registry);
1448        assert!(
1449            report.is_clean(),
1450            "a loaded dialect that answered everything owes nothing:\n{report}"
1451        );
1452    }
1453
1454    /// **Every fix an outside author can read is a fix in their own crate.** A
1455    /// report that names a file inside Razeri is telling an author to edit a
1456    /// tree they do not have, and the fix cannot be performed at all.
1457    #[test]
1458    fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1459        let mut collided = crate::dialect::PlatformRegistry::new();
1460        collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1461        collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1462
1463        let mut fixes: Vec<String> = Vec::new();
1464        for report in [
1465            check(&BrokenPlugin),
1466            check(&HealthyPlugin::new()),
1467            check(&OutsidePlugin::new()),
1468            // The collision and the unregistered-name judgements, which are the
1469            // two registration findings an author can actually reach.
1470            check_with_registry(&Twin, &collided),
1471            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1472        ] {
1473            fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1474        }
1475        assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1476        for fix in &fixes {
1477            for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1478                assert!(
1479                    !fix.contains(path),
1480                    "a fix line names `{path}`, which is not in the author's crate:\n  {fix}"
1481                );
1482            }
1483        }
1484    }
1485
1486    /// **A dialect that claims its own grammar and does not spell it is
1487    /// blocking**, and the finding names the method it owes.
1488    ///
1489    /// `Custom` is a promise that the dialect renders the routine itself, and
1490    /// Razeri keeps its side by writing nothing: no near-neighbour grammar,
1491    /// which fails on deploy, and no refusal, which would tell an author their
1492    /// engine has no routines when it has a whole language. A promise with
1493    /// nothing behind it is therefore a build that stops, and it has to be
1494    /// findable before the deploy rather than during it.
1495    #[test]
1496    fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1497        for (kind, form_method, render_method) in [
1498            (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1499            (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1500            (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1501        ] {
1502            let mut promised = Vec::new();
1503            check_custom_grammar(
1504                CustomGrammar {
1505                    kind,
1506                    grammar: "PL/SQL",
1507                    form_method,
1508                    render_method,
1509                    noun: "routines",
1510                },
1511                |_| None,
1512                &mut promised,
1513            );
1514            assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
1515            assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
1516            assert!(promised[0].what.contains(form_method), "{promised:?}");
1517            assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
1518            assert!(promised[0].fix.contains(render_method), "{promised:?}");
1519
1520            // The same claim, kept: nothing to report.
1521            let mut kept = Vec::new();
1522            check_custom_grammar(
1523                CustomGrammar {
1524                    kind,
1525                    grammar: "PL/SQL",
1526                    form_method,
1527                    render_method,
1528                    noun: "routines",
1529                },
1530                |spec| Some(format!("CREATE {} …", spec.name)),
1531                &mut kept,
1532            );
1533            assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
1534        }
1535    }
1536
1537    /// **A render method that answers with nothing at all is the same defect.**
1538    ///
1539    /// An override that returns an empty string is a routine razeri would emit
1540    /// as blank space in the middle of a schema, which is worse than an error:
1541    /// nothing in the deploy says anything is missing.
1542    #[test]
1543    fn a_custom_render_method_that_returns_nothing_is_blocking() {
1544        let mut findings = Vec::new();
1545        check_custom_grammar(
1546            CustomGrammar {
1547                kind: RoutineKind::Procedure,
1548                grammar: "PL/SQL",
1549                form_method: "procedure_form",
1550                render_method: "render_procedure",
1551                noun: "procedures",
1552            },
1553            |_| Some("   \n".to_owned()),
1554            &mut findings,
1555        );
1556        assert_eq!(findings.len(), 1, "{findings:?}");
1557        assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
1558    }
1559
1560    /// **A `Custom` grammar with no name gives a reader nothing to read.**
1561    #[test]
1562    fn a_custom_grammar_with_no_name_is_blocking() {
1563        let mut findings = Vec::new();
1564        check_custom_grammar(
1565            CustomGrammar {
1566                kind: RoutineKind::ScalarFunction,
1567                grammar: "  ",
1568                form_method: "scalar_function_form",
1569                render_method: "render_scalar_function",
1570                noun: "scalar functions",
1571            },
1572            |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
1573            &mut findings,
1574        );
1575        assert_eq!(findings.len(), 1, "{findings:?}");
1576        assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
1577    }
1578
1579    /// **A stated fact that is empty, and a stated fact that ignores its
1580    /// argument.**
1581    ///
1582    /// Both answers deploy without an error and are wrong in the data: an empty
1583    /// helper body keys every row to nothing, and a file relation that drops
1584    /// its path reads one file for every entity a design binds. Neither is
1585    /// visible anywhere else, which is why the kit grades them.
1586    #[test]
1587    fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
1588        struct Overstated;
1589        impl SqlRenderer for Overstated {
1590            fn render_data_type(&self, dt: &DataType) -> String {
1591                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1592            }
1593            fn quote_identifier(&self, name: &str) -> String {
1594                format!("\"{name}\"")
1595            }
1596            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1597                name.to_owned()
1598            }
1599            fn render_current_timestamp(&self) -> String {
1600                "CURRENT_TIMESTAMP".to_owned()
1601            }
1602            fn dialect_name(&self) -> &'static str {
1603                "overstateddb"
1604            }
1605            fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
1606                Some(razeri_plugin_contract::SidHelperBody {
1607                    language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
1608                    body: "   \n  ",
1609                })
1610            }
1611            fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
1612                Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
1613            }
1614        }
1615        impl ExpressionRenderer for Overstated {}
1616        impl DdlRenderer for Overstated {}
1617        impl DmlRenderer for Overstated {}
1618        impl ProcedureRenderer for Overstated {}
1619        impl FormulaRenderer for Overstated {}
1620        impl TaskRenderer for Overstated {}
1621        impl ReplicationRenderer for Overstated {}
1622        impl StorageRenderer for Overstated {}
1623        impl DialectCapabilities for Overstated {}
1624        impl PlatformDialect for Overstated {
1625            fn info(&self) -> DialectInfo {
1626                DialectInfo {
1627                    name: "overstateddb",
1628                    display_name: "Overstated DB",
1629                    aliases: &[],
1630                    dialect_family: DialectFamily::Custom(""),
1631                    family: "Overstated",
1632                    is_cloud_native: false,
1633                    is_olap: false,
1634                    is_postgres_compatible: false,
1635                }
1636            }
1637        }
1638
1639        let report = check(&Overstated);
1640        for (fragment, why) in [
1641            ("supplies an empty one", "a blank helper body"),
1642            ("does not carry the path", "a file relation that drops its path"),
1643        ] {
1644            let found = report
1645                .findings
1646                .iter()
1647                .find(|f| f.what.contains(fragment))
1648                .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
1649            assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
1650        }
1651
1652        // And silence is not a finding. The fully-answered fixture states
1653        // neither fact — the majority answer — and owes nothing for it.
1654        let quiet = check(&HealthyPlugin::new());
1655        for fragment in ["supplies an empty one", "does not carry the path"] {
1656            assert!(
1657                !quiet.findings.iter().any(|f| f.what.contains(fragment)),
1658                "silence is the default and owes nothing:\n{quiet}"
1659            );
1660        }
1661    }
1662
1663    /// **A stated refusal is a claim, and a claim owes the reader words** — the
1664    /// same grading every other refusal gets, because these words are the only
1665    /// thing an operator finds where the load would have been.
1666    #[test]
1667    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
1668        let mut mute = Vec::new();
1669        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
1670        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
1671        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
1672
1673        let silence = check(&BrokenPlugin);
1674        assert!(
1675            silence
1676                .findings
1677                .iter()
1678                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
1679            "a dialect that never answered is unanswered, not wrong:\n{silence}"
1680        );
1681        assert!(
1682            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
1683            "the fix line names the way to state a measured refusal:\n{silence}"
1684        );
1685    }
1686
1687    /// The report leads with what to fix and says it in words a reader can
1688    /// act on — no identifiers from Razeri's own record-keeping, and a
1689    /// one-line count before the detail.
1690    #[test]
1691    fn the_report_reads_as_a_summary_then_a_fix_list() {
1692        let text = check(&BrokenPlugin).to_string();
1693        let first = text.lines().next().expect("a summary line");
1694        assert!(first.starts_with("brokendb:"), "{first}");
1695        assert!(first.contains("to fix"), "{first}");
1696        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
1697    }
1698}