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//! A dialect owes Razeri more than the methods its traits require: decisions
8//! whose default is another engine's answer, facts it declares twice that must
9//! agree, the wording of its refusals, the names it answers to. Met one at a
10//! time, each is a separate failing test, found only once it fires. This
11//! module is the report that 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//! ```rust
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, Concat, ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities,
47    DialectFamily, EntityRef, FormulaRenderer, MergeRestrictions, MergeShape, ProcedureForm,
48    RoutineKind, RoutineSpec, ScalarFunctionForm, SliceRef, SliceReplacement, SqlRenderer,
49    StorageRenderer, 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    check_schema_fold(dialect, &mut findings);
168    check_deploy_client(&info, &mut findings);
169
170    findings.sort_by_key(|f| f.severity);
171    Report { dialect: info.name.to_owned(), findings }
172}
173
174/// Every spelling this dialect answers to has to reach this dialect: its own
175/// name, and each alias it declares.
176///
177/// That is the whole obligation, and it is one the author can meet from their
178/// own crate — the spellings are the dialect's own, so there is no list inside
179/// Razeri to be missing from and nothing here can ask for a change only Razeri
180/// could make. What is worth saying is a spelling that reaches somewhere else:
181/// one another loaded platform also claims (so it names neither, and Razeri
182/// refuses it), or one that reaches a different platform because the registry
183/// holds this dialect under another key.
184fn check_registration(
185    info: &crate::dialect::DialectInfo,
186    registry: &crate::dialect::PlatformRegistry,
187    findings: &mut Vec<Finding>,
188) {
189    let name = info.name;
190    if matches!(
191        crate::resolve::resolve_dialect_by_name_with_registry(name, registry),
192        Err(crate::ResolveError::UnknownDialect { .. })
193    ) {
194        findings.push(Finding {
195            severity: Severity::Blocking,
196            what: format!(
197                "the name `{name}` does not resolve — nothing in this program registered it"
198            ),
199            fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
200                  Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
201                  it that the program using your dialect calls once. A crate nothing links is a \
202                  crate the registry cannot see."
203                .to_owned(),
204        });
205        return;
206    }
207    for spelling in info.spellings() {
208        match crate::resolve::resolve_dialect_by_name_with_registry(spelling, registry) {
209            Ok(found) if found.info().name == name => {}
210            Ok(found) => findings.push(Finding {
211                severity: Severity::Blocking,
212                what: format!("`{spelling}` reaches `{}` rather than `{name}`", found.info().name),
213                fix: format!(
214                    "the registry holds `{name}` under another key. Register it under its own \
215                     name, or drop `{spelling}` from its aliases."
216                ),
217            }),
218            Err(crate::ResolveError::AmbiguousName { claimed_by, .. }) => findings.push(Finding {
219                severity: Severity::Blocking,
220                what: format!(
221                    "`{spelling}` is claimed by more than one loaded platform ({}), so it names \
222                     none of them — Razeri refuses that spelling rather than guessing which one \
223                     a design meant",
224                    claimed_by.join(" and ")
225                ),
226                fix: format!(
227                    "give this dialect a name no other platform answers to. A platform name is \
228                     read off the loaded dialects' own declared spellings, so a spelling another \
229                     platform already claims is never `{name}`'s."
230                ),
231            }),
232            Err(_) => findings.push(Finding {
233                severity: Severity::Blocking,
234                what: format!(
235                    "`{spelling}` is declared as a spelling of `{name}` but reaches no platform"
236                ),
237                fix: format!(
238                    "the `{name}` in the registry is a different build of this dialect and does \
239                     not declare `{spelling}`. Register the one you are grading."
240                ),
241            }),
242        }
243    }
244}
245
246/// A family is a claim about spelling, so an unmeasured engine claiming a
247/// measured family's name licenses SQL it may reject.
248fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
249    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
250        findings.push(Finding {
251            severity: Severity::Blocking,
252            what: "this dialect's family is a custom one with no name".to_owned(),
253            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
254                  prints and what a reader looks up."
255                .to_owned(),
256        });
257    }
258}
259
260/// The four required methods must answer for every type a design can declare.
261fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
262    let declarable = declarable_types();
263    let empty_column: Vec<String> = declarable
264        .iter()
265        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
266        .map(|dt| format!("{dt:?}"))
267        .collect();
268    if !empty_column.is_empty() {
269        findings.push(Finding {
270            severity: Severity::Blocking,
271            what: format!(
272                "`render_data_type` answers nothing for {} of the types a design can declare",
273                empty_column.len()
274            ),
275            fix: "every declarable type needs a column spelling on this engine, even if the \
276                  spelling is a wider type that stores it. A blank answer becomes a column \
277                  declaration with no type in it."
278                .to_owned(),
279        });
280    }
281    let empty_cast: Vec<String> = declarable
282        .iter()
283        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
284        .map(|dt| format!("{dt:?}"))
285        .collect();
286    if !empty_cast.is_empty() {
287        findings.push(Finding {
288            severity: Severity::Blocking,
289            what: format!(
290                "`render_cast_type` answers nothing for {} declarable types",
291                empty_cast.len()
292            ),
293            fix: "the generated load opens by casting every source column to its declared type, \
294                  so a blank answer fails the load on its first statement."
295                .to_owned(),
296        });
297    }
298    let quoted = SqlRenderer::quote_identifier(dialect, "order");
299    if quoted == "order" {
300        findings.push(Finding {
301            severity: Severity::Blocking,
302            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
303                   reaches this engine bare"
304                .to_owned(),
305            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
306                  backticks, or brackets."
307                .to_owned(),
308        });
309    }
310    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
311        findings.push(Finding {
312            severity: Severity::Blocking,
313            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
314                   a row with it"
315                .to_owned(),
316            fix: "return this engine's own expression for the current moment.".to_owned(),
317        });
318    }
319}
320
321/// **Reading a file in a named encoding**, where the dialect states a way to.
322///
323/// The same path question [`check_decisions`] asks of the plain relation, for
324/// the same reason, plus one only this method can get wrong: an engine that
325/// reads no file at all cannot read one in a named encoding, and a dialect that
326/// answers here and not there states a relation nothing will ever ask it for.
327///
328/// Parameters:
329/// - `dialect`: the plugin under test.
330/// - `probe`: the same quoted path literal the plain relation was asked about.
331/// - `findings`: where anything wrong is recorded.
332fn check_encoded_file_relation(
333    dialect: &dyn PlatformDialect,
334    probe: &str,
335    findings: &mut Vec<Finding>,
336) {
337    let Some(relation) =
338        SqlRenderer::delimited_file_relation_in_encoding(dialect, probe, "windows-1252")
339    else {
340        return;
341    };
342    if !relation.contains(probe) {
343        findings.push(Finding {
344            severity: Severity::Blocking,
345            what: "`delimited_file_relation_in_encoding` returns a relation that does not carry \
346                   the path it was given"
347                .to_owned(),
348            fix: "put the literal you were handed where your engine's file relation takes a \
349                  path. It arrives already quoted and escaped."
350                .to_owned(),
351        });
352    }
353    if SqlRenderer::delimited_file_relation(dialect, probe).is_none() {
354        findings.push(Finding {
355            severity: Severity::Blocking,
356            what: "this dialect states how to read a file in a named encoding and states no way \
357                   to read a file at all"
358                .to_owned(),
359            fix: "answer `delimited_file_relation` as well, or answer neither. Razeri asks the \
360                  first question before the second — a source whose bytes are UTF-8 never \
361                  reaches the encoding arm — so an engine that answers only the second has \
362                  written a relation nothing will ask it for."
363                .to_owned(),
364        });
365    }
366}
367
368/// The decisions a dialect states rather than inherits.
369fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
370    let spelling = SqlRenderer::sid_spelling(dialect);
371    if spelling.platform == UNKNOWN_PLATFORM {
372        findings.push(Finding {
373            severity: Severity::Blocking,
374            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
375                   an `xxhash64` its engine very likely does not have"
376                .to_owned(),
377            fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
378                  TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
379                  hash. If it has no portable hash, name an artifact your prerequisites install \
380                  — never the nearest-looking built-in, which keys a warehouse to a number \
381                  nothing else can reproduce."
382                .to_owned(),
383        });
384    }
385
386    // A join with no name in it writes `(a, '|', b)` or `a  '|'  b`: a
387    // composite key the engine cannot parse, met at the first load that
388    // joins two parts.
389    let join = match spelling.concat {
390        Concat::Function(name) | Concat::FunctionBinary(name) | Concat::Operator(name) => name,
391        Concat::Plus => "+",
392    };
393    if join.trim().is_empty() {
394        findings.push(Finding {
395            severity: Severity::Blocking,
396            what: "this dialect's surrogate-key spelling joins the parts of a composite key \
397                   with a function or operator that has no name"
398                .to_owned(),
399            fix: "name what your engine joins strings with in `SidSpelling::concat`: \
400                  `Concat::Function(\"CONCAT\")`, `Concat::FunctionBinary(\"CONCAT\")` where it \
401                  takes exactly two arguments, or `Concat::Operator(\"||\")` where it has an \
402                  operator and no function."
403                .to_owned(),
404        });
405    }
406
407    // **A stated body that says nothing is worse than no body at all.** Razeri
408    // writes the `CREATE FUNCTION` envelope around whatever comes back, so an
409    // empty one deploys cleanly and returns nothing — every key in the
410    // warehouse is then the sentinel or garbage, under a green deploy.
411    if let Some(supplied) = SqlRenderer::sid_helper_body(dialect)
412        && supplied.body.trim().is_empty()
413    {
414        findings.push(Finding {
415            severity: Severity::Blocking,
416            what: "this dialect says its surrogate-key helper has a body Razeri cannot compose, \
417                   and then supplies an empty one"
418                .to_owned(),
419            fix: "return the body that computes XXH64 with seed 0 over the UTF-8 bytes of the \
420                  one argument, or answer `None` and let Razeri compose the derivation from \
421                  your `sid_spelling`. An empty body deploys without error and keys every row \
422                  to nothing."
423                .to_owned(),
424        });
425    }
426
427    // A file relation that drops its path reads the same file for every entity
428    // — one table filled, the rest filled wrong, and nothing about it fails.
429    let probe = "'/razeri/probe.csv'";
430    if let Some(relation) = SqlRenderer::delimited_file_relation(dialect, probe)
431        && !relation.contains(probe)
432    {
433        findings.push(Finding {
434            severity: Severity::Blocking,
435            what: "`delimited_file_relation` returns a relation that does not carry the path it \
436                   was given"
437                .to_owned(),
438            fix: "put the literal you were handed where your engine's file relation takes a \
439                  path. It arrives already quoted and escaped. A relation that drops it reads \
440                  one file for every entity the design binds, and the generated fill loads the \
441                  wrong rows without failing."
442                .to_owned(),
443        });
444    }
445
446    check_encoded_file_relation(dialect, probe, findings);
447    check_routine_forms(dialect, findings);
448    check_slice_replacement(dialect, findings);
449
450    // The note is what a reader of the DDL learns from, and on an engine with
451    // no keyword of its own it is the ONLY place they can learn it.
452    if let ConstraintEnforcement::DeclaredOnly(declared) =
453        DdlRenderer::constraint_enforcement(dialect)
454        && declared.note.split_whitespace().count() < 5
455    {
456        findings.push(Finding {
457            severity: Severity::Blocking,
458            what: "this dialect says its engine accepts constraints it does not enforce, and \
459                   the note explaining that says almost nothing"
460                .to_owned(),
461            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
462                  guarantee. Say what this engine really does with the declaration, and where \
463                  the guarantee has to come from instead."
464                .to_owned(),
465        });
466    }
467
468    // **The two refusals are different findings, and that is the point of the
469    // second variant.** `None` is silence, and silence is unanswered. A stated
470    // `Unsupported` is a measured claim; what is left to grade there is whether
471    // the sentence is worth reading, because it is the only thing an operator
472    // gets where the load would have been.
473    match DialectCapabilities::merge_shape(dialect) {
474        MergeShape::None => findings.push(Finding {
475            severity: Severity::Unanswered,
476            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
477                   load for it"
478                .to_owned(),
479            fix: "if this engine has a MERGE or an upsert statement, say which in \
480                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
481                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
482                  operator can act on, rather than a silence."
483                .to_owned(),
484        }),
485        MergeShape::Unsupported(orientation) => {
486            check_refusal_copy("the upsert refusal", orientation, findings);
487        }
488        MergeShape::Merge(restrictions) => check_merge_restrictions(restrictions, findings),
489        _ => {}
490    }
491}
492
493/// **A dialect that names a verb for replacing one slice has to put the slice
494/// in it.**
495///
496/// Most dialects answer nothing here, and nothing is a complete answer: Razeri
497/// then writes a delete filtered to the slice, which is correct on every
498/// engine. What is worth grading is the dialect that *does* answer, because its
499/// statement runs in front of a load's write and clears rows. A statement that
500/// forgot the value it was handed does not clear the day this call was given —
501/// it clears some other day, or every day, and the load then reports success
502/// over a table it emptied.
503///
504/// The probe is a daily partition of a date column, which is the case every
505/// partition-level verb is allowed to answer for. A dialect that answers
506/// nothing to it has said the portable form is what it wants.
507fn check_slice_replacement(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
508    const VALUE: &str = "$razeri_slice_probe";
509    let plan = StoragePlan {
510        partition: PartitionStrategy::RangeByTimeDimension {
511            field: "razeri_probe_day".to_owned(),
512            granularity: PartitionGranularity::Day,
513            retention: None,
514            look_ahead: None,
515            window: razeri_plugin_contract::storage_strategy::PartitionWindow::default(),
516        },
517        compression: CompressionStrategy::None,
518        index: IndexStrategy::None,
519    };
520    let day = DataType::date();
521    let columns = ["razeri_probe_day"];
522    let entity = EntityRef {
523        schema: Some("razeri_probe"),
524        name: "razeri_probe_table",
525        is_identity_table: false,
526        columns: &columns,
527        partition_column_type: Some(&day),
528    };
529    let slice = SliceRef { column: "razeri_probe_day", value: VALUE };
530    let Some(answer) = StorageRenderer::render_slice_replacement(dialect, entity, &plan, slice)
531    else {
532        return;
533    };
534
535    let carries = match &answer {
536        SliceReplacement::Emptied(statements) => {
537            !statements.is_empty() && statements.iter().all(|sql| sql.contains(VALUE))
538        }
539        SliceReplacement::WrittenByInsert(clause) => clause.contains(VALUE),
540    };
541    // **A verb that carries the slice is graded nowhere**, deliberately. The
542    // kit cannot tell a measured answer from a lucky one, and a note on every
543    // dialect that got it right would fire on exactly the dialects that need no
544    // help. What is left to say is said where it is useful — in the trait's own
545    // documentation, which an author reads before writing the override.
546    if carries {
547        return;
548    }
549    findings.push(Finding {
550        severity: Severity::Blocking,
551        what: "`render_slice_replacement` returns a statement that does not carry the slice it \
552               was handed"
553            .to_owned(),
554        fix: "put the value you were given where your statement names the slice, and build any \
555              partition NAME from it the same way your extend procedure built it. A replacement \
556              that drops the value clears a slice this call was not given — or every slice — \
557              and the load reports success over what it emptied."
558            .to_owned(),
559    });
560}
561
562/// **The two halves of an evolution, held together**: what this engine says it
563/// keeps when a loaded table changes shape, and the statement it writes for the
564/// change.
565///
566/// An answer left at its default is graded nowhere, for the reason
567/// [`check_merge_restrictions`] states: from outside, a measured *"my engine
568/// does not do that"* and a question nobody asked read the same, and a finding
569/// made out of that silence would fire on every dialect.
570///
571/// What is worth saying is a **pair that disagrees**. A dialect that says its
572/// engine widens a string in place and then writes no statement for a widening
573/// has told Razeri to call the change safe and left the operator to work out
574/// the `ALTER` alone — a gap that reads to them as Razeri not having finished.
575/// And a dialect that answers `None` to a new column has declined the one
576/// evolution every warehouse has, which is nearly always an override that meant
577/// to narrow something and narrowed everything.
578fn check_evolution(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
579    let answers = DialectCapabilities::evolution_answers(dialect);
580    let boolean = DataType::boolean();
581    let long = DataType::varchar(40);
582
583    if DdlRenderer::render_column_evolution(
584        dialect,
585        &ColumnEvolution::AddColumn {
586            schema: Some("s"),
587            table: "t",
588            column: "c",
589            data_type: &boolean,
590        },
591    )
592    .is_none()
593    {
594        findings.push(Finding {
595            severity: Severity::Blocking,
596            what: "this dialect writes no statement for a column that is new, which is the \
597                  commonest change a deployed warehouse ever sees"
598                .to_owned(),
599            fix: "answer `ColumnEvolution::AddColumn` in `render_column_evolution`, or drop the \
600                 override and take the contract's `ALTER TABLE … ADD COLUMN …`, which most \
601                 engines take; some write `ADD` without `COLUMN`, so run it on yours first. An \
602                 engine that genuinely has no way to add a column is worth saying out loud in \
603                 the crate's own documentation."
604                .to_owned(),
605        });
606    }
607
608    if answers.parameterized_string_widening_is_lossless
609        && DdlRenderer::render_column_evolution(
610            dialect,
611            &ColumnEvolution::WidenColumn {
612                schema: Some("s"),
613                table: "t",
614                column: "c",
615                data_type: &long,
616            },
617        )
618        .is_none()
619    {
620        findings.push(Finding {
621            severity: Severity::Note,
622            what: "this dialect says its engine widens a string's declared length in place, and \
623                  writes no statement for the change"
624                .to_owned(),
625            fix: "answer `ColumnEvolution::WidenColumn` in `render_column_evolution` with this \
626                 engine's spelling of it. Without it Razeri tells an operator the widening \
627                 costs nothing and then hands them nothing to run, which reads as Razeri not \
628                 having finished rather than as a statement they own."
629                .to_owned(),
630        });
631    }
632
633    if answers.nested_field_addition_is_lossless
634        && DdlRenderer::render_column_evolution(
635            dialect,
636            &ColumnEvolution::AddNestedField {
637                schema: Some("s"),
638                table: "t",
639                column: "c",
640                field: "f",
641                data_type: &long,
642            },
643        )
644        .is_none()
645    {
646        findings.push(Finding {
647            severity: Severity::Note,
648            what: "this dialect says its engine keeps every value when a record gains a field, \
649                  and writes no statement for the change"
650                .to_owned(),
651            fix: "answer `ColumnEvolution::AddNestedField` in `render_column_evolution`. Some \
652                 engines name the new field; some restate the whole column's type, which is \
653                 why the change carries both the field's path and the column's type with the \
654                 field in it."
655                .to_owned(),
656        });
657    }
658}
659
660/// **What a stated `MERGE` restriction changes about the SQL Razeri writes for
661/// this engine**, said back to the author while the claim is still cheap to
662/// withdraw.
663///
664/// A restriction left at its default costs nothing and is graded nowhere: the
665/// kit cannot tell a measured *"my engine is fine with this"* from a question
666/// nobody asked, and inventing a finding out of that silence would fire on
667/// every dialect. What it can do is report the restriction a dialect does
668/// state, because each one makes Razeri emit something other than the
669/// statement it emits everywhere else.
670fn check_merge_restrictions(restrictions: MergeRestrictions, findings: &mut Vec<Finding>) {
671    if restrictions.slice_pruning_needs_literal {
672        findings.push(Finding {
673            severity: Severity::Note,
674            what: "this dialect says its planner reads one slice of a partitioned table only \
675                   from a literal, so a load scoped to one day cannot simply bind the day"
676                .to_owned(),
677            fix: "Razeri will put the day into the statement text through this engine's \
678                  dynamic-execution form rather than binding it to the load procedure's \
679                  parameter. Confirm that is what your engine wants: the statement is composed \
680                  once per call and its text differs per day, which some planners cache and \
681                  some do not. If a bound parameter does prune here, drop the restriction and \
682                  Razeri binds it like everywhere else."
683                .to_owned(),
684        });
685    }
686}
687
688/// The one override nothing tells an author about, and every formula in every
689/// design goes through it — and the operators a formula is written with.
690fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
691    // An operator answered with no text writes `a  b`, which reads as two
692    // values side by side. `None` is the answer for an operator the engine
693    // lacks, and it is refused by name where a formula needs it.
694    let blank: Vec<&str> = [
695        ("render_power_operator", FormulaRenderer::render_power_operator(dialect)),
696        ("render_ilike_operator", FormulaRenderer::render_ilike_operator(dialect)),
697        ("render_not_ilike_operator", FormulaRenderer::render_not_ilike_operator(dialect)),
698        ("render_concat_operator", FormulaRenderer::render_concat_operator(dialect)),
699    ]
700    .into_iter()
701    .filter(|(_, answer)| answer.as_deref().is_some_and(|op| op.trim().is_empty()))
702    .map(|(method, _)| method)
703    .collect();
704    if !blank.is_empty() {
705        findings.push(Finding {
706            severity: Severity::Blocking,
707            what: format!("{} answers an operator with no text in it", blank.join(", ")),
708            fix: "answer `None` where your engine has no such operator: a formula that needs it \
709                  is then refused by name, and the reader is told to write the function your \
710                  engine has. An empty answer writes the two operands side by side."
711                .to_owned(),
712        });
713    }
714
715    let formula = SqlRenderer::to_formula_renderer(dialect);
716    let dialect_name = SqlRenderer::dialect_name(dialect);
717    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
718        findings.push(Finding {
719            severity: Severity::Blocking,
720            what: "`to_formula_renderer` was not overridden, so every formula in every design \
721                   renders through Razeri's ANSI defaults instead of this dialect"
722                .to_owned(),
723            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
724                  formula this engine sees is silently somebody else's."
725                .to_owned(),
726        });
727    }
728}
729
730/// **The three routine grammars a dialect states**, and what each answer owes.
731///
732/// A grammar Razeri carries needs nothing further. A refusal owes words a
733/// reader can act on. `Custom` owes the render method it promised — see
734/// [`check_custom_grammar`].
735fn check_routine_forms(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
736    match SqlRenderer::procedure_form(dialect) {
737        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
738        // annotated form IS its output. Every other dialect that answers it has
739        // simply not said.
740        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
741        ProcedureForm::Agnostic => findings.push(Finding {
742            severity: Severity::Unanswered,
743            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
744                   annotated form — which no engine parses"
745                .to_owned(),
746            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
747                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
748                  offers instead."
749                .to_owned(),
750        }),
751        ProcedureForm::Unsupported(orientation) => {
752            check_refusal_copy("the stored-procedure refusal", orientation, findings);
753            if DialectCapabilities::supports_stored_procedures(dialect) {
754                findings.push(Finding {
755                    severity: Severity::Blocking,
756                    what: "this dialect says its engine has stored procedures, and its \
757                           procedure form refuses to write one"
758                        .to_owned(),
759                    fix: "decide which is true. If the engine has none, leave \
760                          `DialectCapabilities::supports_stored_procedures` at `false`; if it \
761                          has them, answer `procedure_form` with the grammar it speaks."
762                        .to_owned(),
763                });
764            }
765        }
766        ProcedureForm::Custom(grammar) => check_custom_grammar(
767            CustomGrammar {
768                kind: RoutineKind::Procedure,
769                grammar,
770                form_method: "procedure_form",
771                render_method: "render_procedure",
772                noun: "procedures",
773            },
774            |spec| SqlRenderer::render_procedure(dialect, spec),
775            findings,
776        ),
777        _ => {}
778    }
779    check_procedures_are_used(dialect, findings);
780
781    match SqlRenderer::scalar_function_form(dialect) {
782        ScalarFunctionForm::Unsupported(orientation) => {
783            check_refusal_copy("the scalar-function refusal", orientation, findings);
784        }
785        ScalarFunctionForm::Custom(grammar) => check_custom_grammar(
786            CustomGrammar {
787                kind: RoutineKind::ScalarFunction,
788                grammar,
789                form_method: "scalar_function_form",
790                render_method: "render_scalar_function",
791                noun: "scalar functions",
792            },
793            |spec| SqlRenderer::render_scalar_function(dialect, spec),
794            findings,
795        ),
796        _ => {}
797    }
798
799    match SqlRenderer::table_function_form(dialect) {
800        TableFunctionForm::Unsupported(orientation) => {
801            check_refusal_copy("the table-function refusal", orientation, findings);
802        }
803        TableFunctionForm::Custom(grammar) => check_custom_grammar(
804            CustomGrammar {
805                kind: RoutineKind::TableFunction,
806                grammar,
807                form_method: "table_function_form",
808                render_method: "render_table_function",
809                noun: "table functions",
810            },
811            |spec| SqlRenderer::render_table_function(dialect, spec),
812            findings,
813        ),
814        _ => {}
815    }
816
817    check_parameter_type_spelling(dialect, findings);
818}
819
820/// **A dialect that spells its own procedures has to say its engine has
821/// them**, or Razeri never writes one.
822///
823/// Two answers carry the fact. `procedure_form` says how a procedure is
824/// written; `DialectCapabilities::supports_stored_procedures` says whether a
825/// load is one, and its default is `false`. A dialect that answers `Custom`
826/// and leaves the capability alone renders a procedure nothing asks for:
827/// every load is written as plain statements, and the bundle says the engine
828/// has no stored procedures. A grammar Razeri carries is left out on purpose,
829/// because Razeri keeps that pair for its own engines: `ClickHouse`'s form
830/// writes executable functions, which no load runs as a procedure.
831fn check_procedures_are_used(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
832    let Some(grammar) = SqlRenderer::procedure_form(dialect).custom_grammar() else {
833        return;
834    };
835    if DialectCapabilities::supports_stored_procedures(dialect) {
836        return;
837    }
838    findings.push(Finding {
839        severity: Severity::Blocking,
840        what: format!(
841            "this dialect writes procedures in {grammar:?} and says its engine has none, so no \
842             load is ever written as one"
843        ),
844        fix: "answer `DialectCapabilities::supports_stored_procedures` with `true`. Razeri asks \
845              it before it writes a load as a procedure, and its default is `false`."
846            .to_owned(),
847    });
848}
849
850/// **A parameter's type is not always spelled the way a column's is**, and this
851/// asks whether the dialect has looked.
852///
853/// Oracle is the measured case: a column there takes `NUMBER(19, 0)` and
854/// `VARCHAR2(100)`, and the same types in a parameter list are a compile error
855/// — the bare `NUMBER` and `VARCHAR2` are what a parameter clause takes, with
856/// the precision belonging on the local declaration instead. A dialect that has
857/// not said otherwise gets the column spelling in its parameter lists, which is
858/// right for most engines and a routine that will not compile on one like that.
859///
860/// It is a note and not a failure, because for most engines the two spellings
861/// genuinely are the same and there is nothing to say. And it is asked only of a
862/// dialect spelling a grammar of its own, because that is the population where
863/// nobody has measured the answer yet.
864fn check_parameter_type_spelling(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
865    let spells_its_own = SqlRenderer::procedure_form(dialect).custom_grammar().is_some()
866        || SqlRenderer::scalar_function_form(dialect).custom_grammar().is_some()
867        || SqlRenderer::table_function_form(dialect).custom_grammar().is_some();
868    if !spells_its_own {
869        return;
870    }
871    // A type that carries a length is the only kind the two spellings can
872    // disagree about, so it is the only kind worth asking with.
873    let qualified = DataType::varchar(100);
874    let column = SqlRenderer::render_data_type(dialect, &qualified);
875    let parameter = SqlRenderer::render_parameter_data_type(dialect, &qualified);
876    if column == parameter && column.contains('(') {
877        findings.push(Finding {
878            severity: Severity::Note,
879            what: format!(
880                "a parameter of this routine's would be declared `{parameter}`, the same as a \
881                column of that type"
882            ),
883            fix: "that is right for most engines, so this is only worth a look. If yours refuses \
884                 a length or a precision in a parameter list — Oracle does, and answers \
885                 PLS-00103 at the opening parenthesis — override \
886                 `SqlRenderer::render_parameter_data_type` with the bare spelling. Local \
887                 declarations keep the length either way."
888                .to_owned(),
889        });
890    }
891}
892
893/// One routine kind whose grammar a dialect said it spells itself.
894#[derive(Clone, Copy)]
895struct CustomGrammar {
896    /// Which routine kind the claim is about.
897    kind: RoutineKind,
898    /// The grammar's own name, as the dialect wrote it.
899    grammar: &'static str,
900    /// The method that carried the claim.
901    form_method: &'static str,
902    /// The method that owes the rendering.
903    render_method: &'static str,
904    /// What to call the routines in a sentence.
905    noun: &'static str,
906}
907
908/// **A dialect that says it spells its own grammar has to spell it.**
909///
910/// `Custom` moves the whole rendering to the dialect: Razeri substitutes no
911/// near-neighbour grammar and refuses nothing, because both of those say
912/// something false — one about the engine's syntax, the other about whether the
913/// engine has the routine at all. So the claim is only worth anything with the
914/// method behind it, and a dialect that made the claim and left the method
915/// alone ships a build that stops on the routine.
916///
917/// The check is the question itself: hand the render method the shape it will
918/// receive and see whether anything comes back.
919///
920/// Parameters:
921/// - `claim`: which kind, which grammar, and the two method names to name;
922/// - `render`: the dialect's render method for that kind;
923/// - `findings`: where the report is collected.
924fn check_custom_grammar(
925    claim: CustomGrammar,
926    render: impl Fn(&RoutineSpec) -> Option<String>,
927    findings: &mut Vec<Finding>,
928) {
929    let CustomGrammar { kind, grammar, form_method, render_method, noun } = claim;
930    // The grammar's name is a name, not a refusal: it is read in a report and
931    // in a comment, so it has to say something and does not have to say much.
932    if grammar.trim().is_empty() {
933        findings.push(Finding {
934            severity: Severity::Blocking,
935            what: format!("`{form_method}` says Custom, and names no grammar"),
936            fix: "carry your grammar's own name — `Custom(\"PL/SQL\")`. It is what a reader \
937                  of a report, and of the comment Razeri writes where a routine could not be \
938                  rendered, is told this engine speaks."
939                .to_owned(),
940        });
941    }
942
943    let mut probe = RoutineSpec::new(kind, "razeri_conformance_probe");
944    probe.or_replace = true;
945    if render(&probe).is_none_or(|rendered| rendered.trim().is_empty()) {
946        findings.push(Finding {
947            severity: Severity::Blocking,
948            what: format!(
949                "`{form_method}` says Custom({grammar:?}), so this dialect must render {noun} \
950                 itself — and `{render_method}` returns nothing"
951            ),
952            fix: format!(
953                "override `SqlRenderer::{render_method}`. It receives the routine Razeri \
954                 composed — name, parameters with their types already in your spelling, body, \
955                 comment — and returns the whole CREATE statement your engine parses. Razeri \
956                 emits what you return and writes nothing of its own: a near-neighbour grammar \
957                 fails on deploy, and a refusal would say this engine has no {noun} when it \
958                 has them in a grammar of its own."
959            ),
960        });
961    }
962}
963
964/// A refusal that does not say what to do instead is a wall.
965fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
966    if copy.trim().is_empty() {
967        findings.push(Finding {
968            severity: Severity::Blocking,
969            what: format!("{subject} has no words in it"),
970            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
971                  next thing to try, and it has to be about their engine."
972                .to_owned(),
973        });
974        return;
975    }
976    if copy.split_whitespace().count() < 5 {
977        findings.push(Finding {
978            severity: Severity::Note,
979            what: format!("{subject} is very short"),
980            fix: "a refusal earns its place by naming the alternative — what this engine has \
981                  instead of the thing it cannot do."
982                .to_owned(),
983        });
984    }
985}
986
987/// Fifteen facts are declared twice and must agree.
988fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
989    let caps: &dyn DialectCapabilities = dialect;
990    let ren: &dyn SqlRenderer = dialect;
991    let ddl: &dyn DdlRenderer = dialect;
992    let mut disagreements: Vec<&'static str> = Vec::new();
993
994    macro_rules! pair {
995        ($m:ident, $renderer_value:expr) => {
996            if caps.$m() != $renderer_value {
997                disagreements.push(stringify!($m));
998            }
999        };
1000    }
1001    pair!(supports_on_conflict, ren.supports_on_conflict());
1002    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
1003    pair!(supports_update_from, ren.supports_update_from());
1004    pair!(supports_named_windows, ren.supports_named_windows());
1005    pair!(supports_compound_assignment, ren.supports_compound_assignment());
1006    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
1007    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
1008    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
1009    pair!(uses_alter_table_update, ren.uses_alter_table_update());
1010    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
1011    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
1012    pair!(supports_sync_drop, ddl.supports_sync_drop());
1013    pair!(supports_force_drop, ddl.supports_force_drop());
1014
1015    // **The fourteenth, whose two sides are different types.** The capability
1016    // is a boolean; the renderer answer is a form that either renders or
1017    // carries a refusal. Both are read — the build door reads the form, and
1018    // registry-walking rosters read the capability — so a dialect claiming a
1019    // table function on one side and refusing on the other has an engine that
1020    // both does and does not have one. `SQLite` was exactly that on the day
1021    // this check was written: `false` since the capability existed, and a
1022    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
1023    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
1024        disagreements.push("supports_table_functions");
1025    }
1026    // **The fifteenth, the same shape for a scalar function.** The build door
1027    // reads the capability; the renderer reads the form.
1028    if caps.supports_stored_functions() != ren.scalar_function_form().refusal().is_none() {
1029        disagreements.push("supports_stored_functions");
1030    }
1031
1032    if !disagreements.is_empty() {
1033        findings.push(Finding {
1034            severity: Severity::Blocking,
1035            what: format!(
1036                "this dialect declares {} fact(s) two ways and the two disagree: {}",
1037                disagreements.len(),
1038                disagreements.join(", ")
1039            ),
1040            fix: "each of these is declared once on the capability trait and once in the \
1041                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
1042                  and move the other one to match."
1043                .to_owned(),
1044        });
1045    }
1046}
1047
1048/// **A dialect that folds each schema into an object's name has to fold it
1049/// everywhere.**
1050///
1051/// Razeri writes most names itself, asking `fold_schema_into_name`, and some
1052/// through the dialect's own `qualify_name`. The two have to name the same
1053/// object, or a table is created under one name and read under another. And a
1054/// fold is asked for only where the engine has no schemas: on one that has
1055/// them, the schema is written apart from the name, and a fold says otherwise.
1056fn check_schema_fold(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
1057    let (schema, name) = ("razeri_probe_raw", "razeri_probe_table");
1058    let Some(folded) = SqlRenderer::fold_schema_into_name(dialect, schema, name) else {
1059        return;
1060    };
1061    if DialectCapabilities::supports_schemas(dialect) {
1062        findings.push(Finding {
1063            severity: Severity::Blocking,
1064            what: "this dialect folds a schema into an object's name on an engine it says has \
1065                   schemas"
1066                .to_owned(),
1067            fix: "answer `fold_schema_into_name` with `None`. A fold is for an engine with no \
1068                  schemas, and only there is it asked; or answer \
1069                  `DialectCapabilities::supports_schemas` with `false` if your engine has none."
1070                .to_owned(),
1071        });
1072        return;
1073    }
1074    let expected = SqlRenderer::quote_identifier(dialect, &folded);
1075    let qualified = SqlRenderer::qualify_name(dialect, Some(schema), name);
1076    if qualified != expected {
1077        findings.push(Finding {
1078            severity: Severity::Blocking,
1079            what: format!(
1080                "`fold_schema_into_name` names an object `{expected}` and `qualify_name` names \
1081                 the same object `{qualified}`"
1082            ),
1083            fix: "make `qualify_name` answer the folded name, quoted, when it is given a \
1084                  schema. Razeri names some objects through it and the rest through the fold, \
1085                  so a difference creates a table under one name and reads it under another."
1086                .to_owned(),
1087        });
1088    }
1089}
1090
1091/// **A described deploy client has to be told which file to run.**
1092///
1093/// The generated script substitutes the file for `{file}` in the arguments
1094/// the dialect names, and reads nothing else, so an argument list without it
1095/// runs the client with no SQL at all — and a client given no file usually
1096/// waits for one on its input.
1097fn check_deploy_client(info: &crate::dialect::DialectInfo, findings: &mut Vec<Finding>) {
1098    let Some(crate::dialect::DeployScript::Client(client)) = info.deploy_script else {
1099        return;
1100    };
1101    if client.command.trim().is_empty() {
1102        findings.push(Finding {
1103            severity: Severity::Blocking,
1104            what: "this dialect describes a deploy client with no command".to_owned(),
1105            fix: "name the client's command as an operator has it on `PATH`.".to_owned(),
1106        });
1107    }
1108    let unnamed: Vec<&str> = [("run_file", Some(client.run_file)), ("read_rows", client.read_rows)]
1109        .into_iter()
1110        .filter_map(|(field, args)| {
1111            args.filter(|args| !args.iter().any(|arg| arg.contains("{file}"))).map(|_| field)
1112        })
1113        .collect();
1114    if !unnamed.is_empty() {
1115        findings.push(Finding {
1116            severity: Severity::Blocking,
1117            what: format!(
1118                "the deploy client's {} never names the file to run",
1119                unnamed.join(" and ")
1120            ),
1121            fix: "put `{file}` where your client takes the file of SQL to run — on its own, or \
1122                  inside an argument such as `--file={file}`. The script writes the file there \
1123                  and nowhere else."
1124                .to_owned(),
1125        });
1126    }
1127}
1128
1129/// Every type a design can declare, which is the set the required methods owe
1130/// an answer for.
1131///
1132/// The last four are the ones whose spelling actually varies between engines:
1133/// an array, a map, a struct, and an array of structs. A dialect that answers
1134/// only for scalars is a dialect that has not been graded on the types a
1135/// nested source table is made of.
1136fn declarable_types() -> Vec<DataType> {
1137    vec![
1138        DataType::tinyint(),
1139        DataType::smallint(),
1140        DataType::int(),
1141        DataType::bigint(),
1142        DataType::double(),
1143        DataType::boolean(),
1144        DataType::text(),
1145        DataType::varchar(200),
1146        DataType::uuid(),
1147        DataType::json(),
1148        DataType::date(),
1149        DataType::timestamp(),
1150        DataType::timestamp_tz(),
1151        DataType::decimal(18, 2),
1152        DataType::Array(ArrayDataType::new(DataType::varchar(100))),
1153        DataType::Map(MapDataType::new(DataType::varchar(50), DataType::bigint())),
1154        DataType::Struct(StructDataType::new(vec![
1155            StructField::new("street", DataType::varchar(200)),
1156            StructField::new("zip", DataType::varchar(10)),
1157        ])),
1158        DataType::Array(ArrayDataType::new(DataType::Struct(StructDataType::new(vec![
1159            StructField::new("key", DataType::varchar(100)),
1160            StructField::new("value", DataType::varchar(1000)),
1161        ])))),
1162    ]
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168    use crate::dialect::DialectInfo;
1169    use razeri_plugin_contract::{
1170        Concat, DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions,
1171        ProcedureRenderer, ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer,
1172        TaskRenderer, TextCast,
1173    };
1174    use std::marker::PhantomData;
1175
1176    // ── The broken fixture ────────────────────────────────────────────
1177    //
1178    // A plugin that compiles, registers nothing it should, and answers every
1179    // question with somebody else's default. This is what the kit exists to
1180    // catch, and it is deliberately the plugin an author gets by implementing
1181    // only the four required methods and stopping.
1182
1183    struct BrokenPlugin;
1184
1185    impl SqlRenderer for BrokenPlugin {
1186        fn render_data_type(&self, _dt: &DataType) -> String {
1187            String::new()
1188        }
1189        fn quote_identifier(&self, name: &str) -> String {
1190            name.to_owned()
1191        }
1192        fn render_cast_type(&self, _dt: &DataType) -> String {
1193            String::new()
1194        }
1195        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1196            name.to_owned()
1197        }
1198        fn render_current_timestamp(&self) -> String {
1199            String::new()
1200        }
1201        fn dialect_name(&self) -> &'static str {
1202            "brokendb"
1203        }
1204        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
1205        // and one fact declared twice, two ways.
1206        fn supports_on_conflict(&self) -> bool {
1207            true
1208        }
1209    }
1210    impl ExpressionRenderer for BrokenPlugin {}
1211    impl DdlRenderer for BrokenPlugin {}
1212    impl DmlRenderer for BrokenPlugin {}
1213    impl ProcedureRenderer for BrokenPlugin {}
1214    impl FormulaRenderer for BrokenPlugin {}
1215    impl TaskRenderer for BrokenPlugin {}
1216    impl ReplicationRenderer for BrokenPlugin {}
1217    impl StorageRenderer for BrokenPlugin {}
1218    impl DialectCapabilities for BrokenPlugin {
1219        fn supports_on_conflict(&self) -> bool {
1220            false
1221        }
1222    }
1223    impl PlatformDialect for BrokenPlugin {
1224        fn info(&self) -> DialectInfo {
1225            DialectInfo {
1226                name: "brokendb",
1227                display_name: "Broken DB",
1228                aliases: &[],
1229                dialect_family: DialectFamily::Custom(""),
1230                family: "Broken",
1231                is_cloud_native: false,
1232                is_olap: false,
1233                is_postgres_compatible: false,
1234                versions: &[],
1235                deploy_script: None,
1236            }
1237        }
1238    }
1239
1240    // ── The answered fixture, under two names ─────────────────────────
1241    //
1242    // The same plugin with every question answered, carrying the name it
1243    // registers under as a type parameter. Neither name is one Razeri knows a
1244    // spelling for; the difference is that one of them is in the registry and
1245    // the other is in nothing at all. Those are two different facts about a
1246    // dialect, and they read as two different reports.
1247
1248    trait FixtureName: Send + Sync + 'static {
1249        /// The registry name this fixture answers to.
1250        const NAME: &'static str;
1251        /// How this fixture's engine joins the parts of a composite key. Most
1252        /// of them take the inherited variadic `CONCAT`; the one that does not
1253        /// is the point of it being a knob.
1254        const CONCAT: Concat = Concat::Function("CONCAT");
1255        /// This fixture's engine's exponentiation operator; `None` where it
1256        /// has none.
1257        const POWER: Option<&'static str> = Some("^");
1258        /// The client this fixture's deploy script drives, if it names one.
1259        const DEPLOY: Option<crate::dialect::DeployScript> = None;
1260    }
1261
1262    /// Answered, and nothing linked it — what an author sees running the report
1263    /// from a crate whose registration nobody calls.
1264    struct Unlinked;
1265    impl FixtureName for Unlinked {
1266        const NAME: &'static str = "healthydb";
1267    }
1268
1269    /// Answered and registered, under a name Razeri ships no spelling for —
1270    /// the shape of every dialect written outside Razeri's own tree.
1271    struct Outside;
1272    impl FixtureName for Outside {
1273        const NAME: &'static str = "outsidedb";
1274    }
1275
1276    /// Answered, and joining composite keys with a `CONCAT` that takes exactly
1277    /// two arguments — the shape of Db2 and of older Oracle releases. Nothing
1278    /// about the join is an obligation, so the report on it must read exactly
1279    /// like the report on a variadic engine.
1280    struct BinaryConcat;
1281    impl FixtureName for BinaryConcat {
1282        const NAME: &'static str = "binaryconcatdb";
1283        const CONCAT: Concat = Concat::FunctionBinary("CONCAT");
1284    }
1285
1286    /// Answered, and joining composite keys with the `||` operator — the
1287    /// shape of an engine with no `CONCAT` function at all. Like the
1288    /// two-argument join, it is a decision and owes nothing.
1289    struct OperatorConcat;
1290    impl FixtureName for OperatorConcat {
1291        const NAME: &'static str = "operatorconcatdb";
1292        const CONCAT: Concat = Concat::Operator("||");
1293    }
1294
1295    /// Answered, with a join that names nothing.
1296    struct NamelessConcat;
1297    impl FixtureName for NamelessConcat {
1298        const NAME: &'static str = "namelessconcatdb";
1299        const CONCAT: Concat = Concat::Operator(" ");
1300    }
1301
1302    /// Answered, with an engine that raises to a power only through a
1303    /// function: no operator, said out loud.
1304    struct NoPowerOperator;
1305    impl FixtureName for NoPowerOperator {
1306        const NAME: &'static str = "nopowerdb";
1307        const POWER: Option<&'static str> = None;
1308    }
1309
1310    /// Answered, with an operator spelled as nothing at all.
1311    struct BlankPowerOperator;
1312    impl FixtureName for BlankPowerOperator {
1313        const NAME: &'static str = "blankpowerdb";
1314        const POWER: Option<&'static str> = Some(" ");
1315    }
1316
1317    /// Answered, with a deploy client described well.
1318    struct DescribedClient;
1319    impl FixtureName for DescribedClient {
1320        const NAME: &'static str = "describedclientdb";
1321        const DEPLOY: Option<crate::dialect::DeployScript> =
1322            Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1323                command: "sqlite3",
1324                run_file: &["-bail", "{target}", ".read {file}"],
1325                read_rows: Some(&["-csv", "-noheader", "{target}", ".read {file}"]),
1326            }));
1327    }
1328
1329    /// Answered, with a deploy client that is never told which file to run.
1330    struct FilelessClient;
1331    impl FixtureName for FilelessClient {
1332        const NAME: &'static str = "filelessclientdb";
1333        const DEPLOY: Option<crate::dialect::DeployScript> =
1334            Some(crate::dialect::DeployScript::Client(crate::dialect::DeployClient {
1335                command: "sqlite3",
1336                run_file: &["-bail", "{target}"],
1337                read_rows: None,
1338            }));
1339    }
1340
1341    struct Answered<N: FixtureName>(PhantomData<N>);
1342
1343    impl<N: FixtureName> Answered<N> {
1344        const fn new() -> Self {
1345            Self(PhantomData)
1346        }
1347    }
1348
1349    type HealthyPlugin = Answered<Unlinked>;
1350    type OutsidePlugin = Answered<Outside>;
1351
1352    inventory::submit! {
1353        crate::builder::DialectRegistration::new(
1354            Outside::NAME,
1355            || std::sync::Arc::new(OutsidePlugin::new()),
1356        )
1357    }
1358
1359    impl<N: FixtureName> SqlRenderer for Answered<N> {
1360        fn render_data_type(&self, dt: &DataType) -> String {
1361            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1362        }
1363        fn quote_identifier(&self, name: &str) -> String {
1364            format!("\"{name}\"")
1365        }
1366        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1367            schema.map_or_else(
1368                || self.quote_identifier(name),
1369                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
1370            )
1371        }
1372        fn render_current_timestamp(&self) -> String {
1373            "NOW()".to_owned()
1374        }
1375        fn dialect_name(&self) -> &'static str {
1376            N::NAME
1377        }
1378        fn sid_spelling(&self) -> SidSpelling {
1379            SidSpelling {
1380                platform: N::NAME,
1381                hash: ("answered_xxhash64(", ")"),
1382                cast_type: TextCast::Direct("STRING"),
1383                concat: N::CONCAT,
1384                through_helper: false,
1385                ..razeri_plugin_contract::ANSI_SID_SPELLING
1386            }
1387        }
1388        fn procedure_form(&self) -> ProcedureForm {
1389            ProcedureForm::Unsupported(
1390                "This engine has no stored procedures; write the load as a scheduled query \
1391                 instead.",
1392            )
1393        }
1394        fn scalar_function_form(&self) -> ScalarFunctionForm {
1395            ScalarFunctionForm::PostgreSql
1396        }
1397        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
1398            Box::new(Self::new())
1399        }
1400    }
1401    impl<N: FixtureName> ExpressionRenderer for Answered<N> {}
1402    impl<N: FixtureName> DdlRenderer for Answered<N> {}
1403    impl<N: FixtureName> DmlRenderer for Answered<N> {}
1404    impl<N: FixtureName> ProcedureRenderer for Answered<N> {}
1405    impl<N: FixtureName> FormulaRenderer for Answered<N> {
1406        fn render_power_operator(&self) -> Option<String> {
1407            N::POWER.map(str::to_owned)
1408        }
1409    }
1410    impl<N: FixtureName> TaskRenderer for Answered<N> {}
1411    impl<N: FixtureName> ReplicationRenderer for Answered<N> {}
1412    impl<N: FixtureName> StorageRenderer for Answered<N> {}
1413    impl<N: FixtureName> DialectCapabilities for Answered<N> {
1414        fn merge_shape(&self) -> MergeShape {
1415            MergeShape::Merge(MergeRestrictions::none())
1416        }
1417    }
1418    impl<N: FixtureName> PlatformDialect for Answered<N> {
1419        fn info(&self) -> DialectInfo {
1420            DialectInfo {
1421                name: N::NAME,
1422                display_name: "An Answered Engine",
1423                // It answers to its own name and nothing else, which is the
1424                // shape of a plugin that declares no extra spellings.
1425                aliases: &[],
1426                dialect_family: DialectFamily::Custom(N::NAME),
1427                family: "Answered",
1428                is_cloud_native: false,
1429                is_olap: false,
1430                is_postgres_compatible: false,
1431                versions: &[],
1432                deploy_script: N::DEPLOY,
1433            }
1434        }
1435    }
1436
1437    // ── The twin: a dialect that claims a name another platform answers to ──
1438    //
1439    // Answered in every other respect, so the only thing its report can be
1440    // about is the spelling it took.
1441
1442    struct Twin;
1443
1444    impl SqlRenderer for Twin {
1445        fn render_data_type(&self, dt: &DataType) -> String {
1446            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1447        }
1448        fn quote_identifier(&self, name: &str) -> String {
1449            format!("\"{name}\"")
1450        }
1451        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1452            self.quote_identifier(name)
1453        }
1454        fn render_current_timestamp(&self) -> String {
1455            "NOW()".to_owned()
1456        }
1457    }
1458    impl ExpressionRenderer for Twin {}
1459    impl DdlRenderer for Twin {}
1460    impl DmlRenderer for Twin {}
1461    impl ProcedureRenderer for Twin {}
1462    impl FormulaRenderer for Twin {}
1463    impl TaskRenderer for Twin {}
1464    impl ReplicationRenderer for Twin {}
1465    impl StorageRenderer for Twin {}
1466    impl DialectCapabilities for Twin {}
1467    impl PlatformDialect for Twin {
1468        fn info(&self) -> DialectInfo {
1469            DialectInfo {
1470                name: "twindb",
1471                display_name: "Twin DB",
1472                // The name the answered fixture already answers to.
1473                aliases: &["healthydb"],
1474                dialect_family: DialectFamily::Custom("twindb"),
1475                family: "Twin",
1476                is_cloud_native: false,
1477                is_olap: false,
1478                is_postgres_compatible: false,
1479                versions: &[],
1480                deploy_script: None,
1481            }
1482        }
1483    }
1484
1485    /// Every finding whose `what` line mentions one of these subjects.
1486    fn mentions(report: &Report, needle: &str) -> bool {
1487        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
1488    }
1489
1490    /// **The kit's own red.** A plugin that implements the four required
1491    /// methods and stops compiles and registers, and every one of these
1492    /// findings is a defect a shipped dialect actually had.
1493    #[test]
1494    fn the_kit_catches_a_deliberately_broken_plugin() {
1495        let report = check(&BrokenPlugin);
1496        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
1497
1498        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
1499        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
1500        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
1501        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
1502        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
1503        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
1504        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
1505        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
1506        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
1507        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
1508        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
1509
1510        assert!(
1511            report.count(Severity::Blocking) >= 8,
1512            "the broken plugin owes more than {} blocking fixes:\n{report}",
1513            report.count(Severity::Blocking)
1514        );
1515    }
1516
1517    /// **The kit does not cry wolf.** The same plugin with its questions
1518    /// answered reports only what is actually true of it: it is not
1519    /// registered in this test binary, and nothing else.
1520    #[test]
1521    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
1522        let report = check(&HealthyPlugin::new());
1523        let unregistered: Vec<&Finding> =
1524            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
1525        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
1526        assert_eq!(
1527            report.findings.len(),
1528            1,
1529            "a plugin that answered every question owes nothing else:\n{report}"
1530        );
1531    }
1532
1533    /// **A join written for a two-argument `CONCAT` is an answer, not a
1534    /// shortfall.** The report on a dialect that states it reads exactly like
1535    /// the report on a variadic one, and the key it renders nests pairwise
1536    /// instead of widening a call the engine does not have.
1537    #[test]
1538    fn the_kit_takes_a_two_argument_concat_as_an_answer_like_any_other() {
1539        let binary = Answered::<BinaryConcat>::new();
1540        let report = check(&binary);
1541        assert_eq!(
1542            report.findings.len(),
1543            check(&HealthyPlugin::new()).findings.len(),
1544            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1545        );
1546        assert!(
1547            !report.to_string().contains("joins"),
1548            "nothing about the join is outstanding:\n{report}"
1549        );
1550
1551        let key = razeri_plugin_contract::sid::sid_expression(
1552            SqlRenderer::sid_spelling(&binary),
1553            None,
1554            &["a".to_owned(), "b".to_owned(), "c".to_owned()],
1555        );
1556        assert!(
1557            key.contains("CONCAT(CONCAT("),
1558            "a two-argument engine gets one call per pair:\n{key}"
1559        );
1560    }
1561
1562    /// **A join written with an operator is an answer too**, and the key it
1563    /// renders is one expression with the operator between each part and the
1564    /// separator.
1565    #[test]
1566    fn the_kit_takes_an_operator_join_as_an_answer_like_any_other() {
1567        let operator = Answered::<OperatorConcat>::new();
1568        let report = check(&operator);
1569        assert_eq!(
1570            report.findings.len(),
1571            check(&HealthyPlugin::new()).findings.len(),
1572            "the join form is a decision, so stating this one owes nothing extra:\n{report}"
1573        );
1574        let key = razeri_plugin_contract::sid::sid_expression(
1575            SqlRenderer::sid_spelling(&operator),
1576            None,
1577            &["a".to_owned(), "b".to_owned()],
1578        );
1579        assert!(key.contains(" || '|' || "), "the operator joins each part:\n{key}");
1580        assert!(!key.contains("CONCAT"), "an operator engine calls no function:\n{key}");
1581    }
1582
1583    /// **A join that names nothing is refused**, because the key it writes is
1584    /// one the engine cannot parse.
1585    #[test]
1586    fn a_join_that_names_nothing_must_be_fixed() {
1587        let report = check(&Answered::<NamelessConcat>::new());
1588        assert!(
1589            report.findings.iter().any(|finding| finding.severity == Severity::Blocking
1590                && finding.what.contains("joins the parts of a composite key")),
1591            "{report}"
1592        );
1593        assert!(
1594            !check(&Answered::<OperatorConcat>::new())
1595                .findings
1596                .iter()
1597                .any(|finding| finding.what.contains("joins the parts of a composite key")),
1598            "a named operator is not the finding"
1599        );
1600    }
1601
1602    /// **An operator the engine lacks is answered `None`, and that is an
1603    /// answer**; an operator answered with no text is the finding, because it
1604    /// writes two operands side by side.
1605    #[test]
1606    fn an_operator_is_answered_none_where_the_engine_lacks_it_and_never_blank() {
1607        let none = check(&Answered::<NoPowerOperator>::new());
1608        assert_eq!(
1609            none.findings.len(),
1610            check(&HealthyPlugin::new()).findings.len(),
1611            "saying the engine has no such operator owes nothing:\n{none}"
1612        );
1613        let blank = check(&Answered::<BlankPowerOperator>::new());
1614        assert!(
1615            blank.findings.iter().any(|finding| finding.severity == Severity::Blocking
1616                && finding.what.contains("render_power_operator")),
1617            "{blank}"
1618        );
1619    }
1620
1621    /// **A described deploy client is an answer; one that is never told which
1622    /// file to run is the finding.**
1623    #[test]
1624    fn a_deploy_client_has_to_be_told_which_file_to_run() {
1625        let described = check(&Answered::<DescribedClient>::new());
1626        assert_eq!(
1627            described.findings.len(),
1628            check(&HealthyPlugin::new()).findings.len(),
1629            "a client described in full owes nothing:\n{described}"
1630        );
1631        let fileless = check(&Answered::<FilelessClient>::new());
1632        assert!(
1633            fileless.findings.iter().any(|finding| finding.severity == Severity::Blocking
1634                && finding.what.contains("run_file never names the file")),
1635            "{fileless}"
1636        );
1637    }
1638
1639    /// **A fold has to agree with `qualify_name`, and is for an engine with no
1640    /// schemas.** `SCHEMAS`: what the engine says about schemas. `AGREES`:
1641    /// whether `qualify_name` answers the folded name.
1642    #[test]
1643    fn a_schema_fold_agrees_with_qualify_name_on_an_engine_with_no_schemas() {
1644        struct Folder<const SCHEMAS: bool, const AGREES: bool>;
1645        impl<const SCHEMAS: bool, const AGREES: bool> SqlRenderer for Folder<SCHEMAS, AGREES> {
1646            fn render_data_type(&self, dt: &DataType) -> String {
1647                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1648            }
1649            fn quote_identifier(&self, name: &str) -> String {
1650                format!("\"{name}\"")
1651            }
1652            fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
1653                match schema {
1654                    Some(schema) if AGREES => self.quote_identifier(&format!("{schema}_{name}")),
1655                    Some(schema) => format!("{schema}.{}", self.quote_identifier(name)),
1656                    None => self.quote_identifier(name),
1657                }
1658            }
1659            fn render_current_timestamp(&self) -> String {
1660                "CURRENT_TIMESTAMP".to_owned()
1661            }
1662            fn fold_schema_into_name(&self, schema: &str, name: &str) -> Option<String> {
1663                Some(format!("{schema}_{name}"))
1664            }
1665        }
1666        impl<const S: bool, const A: bool> ExpressionRenderer for Folder<S, A> {}
1667        impl<const S: bool, const A: bool> DdlRenderer for Folder<S, A> {}
1668        impl<const S: bool, const A: bool> DmlRenderer for Folder<S, A> {}
1669        impl<const S: bool, const A: bool> ProcedureRenderer for Folder<S, A> {}
1670        impl<const S: bool, const A: bool> FormulaRenderer for Folder<S, A> {}
1671        impl<const S: bool, const A: bool> TaskRenderer for Folder<S, A> {}
1672        impl<const S: bool, const A: bool> ReplicationRenderer for Folder<S, A> {}
1673        impl<const S: bool, const A: bool> StorageRenderer for Folder<S, A> {}
1674        impl<const S: bool, const A: bool> DialectCapabilities for Folder<S, A> {
1675            fn supports_schemas(&self) -> bool {
1676                S
1677            }
1678        }
1679        impl<const S: bool, const A: bool> PlatformDialect for Folder<S, A> {
1680            fn info(&self) -> DialectInfo {
1681                DialectInfo {
1682                    name: "folderdb",
1683                    display_name: "Folder DB",
1684                    aliases: &[],
1685                    dialect_family: DialectFamily::Custom("folderdb"),
1686                    family: "Folder",
1687                    is_cloud_native: false,
1688                    is_olap: false,
1689                    is_postgres_compatible: false,
1690                    versions: &[],
1691                    deploy_script: None,
1692                }
1693            }
1694        }
1695
1696        let fold_findings = |dialect: &dyn PlatformDialect| {
1697            let mut findings = Vec::new();
1698            check_schema_fold(dialect, &mut findings);
1699            findings
1700        };
1701        assert!(fold_findings(&Folder::<false, true>).is_empty(), "a fold kept everywhere");
1702        let disagrees = fold_findings(&Folder::<false, false>);
1703        assert_eq!(disagrees.len(), 1, "{disagrees:?}");
1704        assert!(disagrees[0].what.contains("`qualify_name` names"), "{disagrees:?}");
1705        let has_schemas = fold_findings(&Folder::<true, true>);
1706        assert_eq!(has_schemas.len(), 1, "{has_schemas:?}");
1707        assert!(has_schemas[0].what.contains("says has schemas"), "{has_schemas:?}");
1708    }
1709
1710    /// **A stated `MERGE` restriction is reported; a default one is not.**
1711    ///
1712    /// The asymmetry is the point. A dialect that says its planner needs a
1713    /// literal slice gets Razeri's plan for that answer back, while the engine
1714    /// it is describing is still in front of the author. A dialect that has
1715    /// said nothing gets nothing, because the kit cannot tell a measured
1716    /// *"a parameter is fine here"* from a question never asked, and a finding
1717    /// made out of that silence would fire on every dialect alike.
1718    #[test]
1719    fn a_stated_merge_restriction_is_reported_and_a_default_one_is_not() {
1720        let mut quiet = Vec::new();
1721        check_merge_restrictions(MergeRestrictions::none(), &mut quiet);
1722        assert!(quiet.is_empty(), "a dialect with no stated restriction is asked nothing");
1723
1724        let mut stated = Vec::new();
1725        check_merge_restrictions(MergeRestrictions::requiring_a_literal_slice(), &mut stated);
1726        assert_eq!(stated.len(), 1, "the stated restriction is reported once");
1727        assert_eq!(stated[0].severity, Severity::Note, "nothing is broken; it is worth a look");
1728        assert!(
1729            stated[0].what.contains("literal"),
1730            "the finding names the restriction:\n{}",
1731            stated[0].what
1732        );
1733        assert!(
1734            stated[0].fix.contains("dynamic-execution"),
1735            "and says what Razeri does with the answer:\n{}",
1736            stated[0].fix
1737        );
1738
1739        // The other restriction travels on the same struct and must not drag
1740        // the new finding along with it.
1741        let mut two_clauses = Vec::new();
1742        check_merge_restrictions(MergeRestrictions::requiring_two_when_clauses(), &mut two_clauses);
1743        assert!(two_clauses.is_empty(), "one restriction is not the other");
1744    }
1745
1746    /// **A dialect written outside Razeri reaches a clean report.** It is
1747    /// reached the way every out-of-tree dialect is reached — it registered, and
1748    /// the resolver reads the loaded dialects' own declared spellings — so there
1749    /// is nothing for Razeri to be missing it from and nothing for the report to
1750    /// ask of its author.
1751    #[test]
1752    fn a_dialect_registered_under_its_own_name_reaches_a_clean_report() {
1753        assert!(
1754            crate::resolve_dialect_by_name(Outside::NAME).is_ok(),
1755            "the fixture registers through the registry, which is the whole path being graded"
1756        );
1757        // The precondition that used to read "no row in Razeri's table": no
1758        // other loaded platform declares this name, so the fixture is reached
1759        // only because it registered and said so itself.
1760        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1761        assert!(
1762            registry
1763                .list()
1764                .iter()
1765                .filter(|info| info.name != Outside::NAME)
1766                .all(|info| !info.answers_to(Outside::NAME)),
1767            "the fixture's name must be one no other platform claims"
1768        );
1769
1770        let report = check(&OutsidePlugin::new());
1771        assert!(report.findings.is_empty(), "nothing is owed here:\n{report}");
1772        assert!(report.is_clean(), "and the report says so:\n{report}");
1773        assert_eq!(report.count(Severity::Blocking), 0, "{report}");
1774        assert!(
1775            report.to_string().contains("nothing to fix"),
1776            "a clean dialect is told it is clean:\n{report}"
1777        );
1778    }
1779
1780    /// **A platform cannot be reachable under an alias but not under its own
1781    /// name.** A dialect declares its own spellings and its own name leads
1782    /// them, so a platform reachable as `mynew_db` and never as `mynewdb`
1783    /// cannot be written down — and this is the assertion that keeps it that
1784    /// way.
1785    #[test]
1786    fn a_platform_is_never_reachable_by_an_alias_but_not_by_its_own_name() {
1787        let registry = crate::dialect::PlatformRegistry::with_auto_discovered();
1788        for info in registry.list() {
1789            assert_eq!(
1790                info.spellings().first().copied(),
1791                Some(info.name),
1792                "a platform's own name leads its spellings"
1793            );
1794            assert!(info.answers_to(info.name), "{} answers to its own name", info.name);
1795        }
1796    }
1797
1798    /// **A name another platform already answers to is a collision, not a
1799    /// registration.** A dialect claiming a spelling Healthy DB answers to is
1800    /// named as the collision it is — with both platforms named, and a fix its
1801    /// author can actually perform — rather than being reported as an
1802    /// unregistered platform, which is a different problem with a different fix.
1803    #[test]
1804    fn a_name_razeri_reads_as_another_platform_is_blocking_and_says_which() {
1805        let mut registry = crate::dialect::PlatformRegistry::new();
1806        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1807        registry.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1808
1809        let report = check_with_registry(&Twin, &registry);
1810        let collision = report
1811            .findings
1812            .iter()
1813            .find(|f| f.what.contains("claimed by more than one loaded platform"))
1814            .unwrap_or_else(|| panic!("the collision is reported:\n{report}"));
1815        assert_eq!(collision.severity, Severity::Blocking, "{collision:?}");
1816        assert!(collision.what.contains("healthydb"), "the other platform is named: {collision:?}");
1817        assert!(collision.what.contains("twindb"), "{collision:?}");
1818        assert!(collision.fix.contains("name no other platform answers to"), "{collision:?}");
1819        assert!(
1820            !mentions(&report, "does not resolve"),
1821            "a collision is not an unregistered platform:\n{report}"
1822        );
1823    }
1824
1825    /// A platform reached under its own name owes nothing, whatever the case or
1826    /// the surrounding whitespace, and a name nothing has ever heard of is
1827    /// simply unknown rather than a finding against this dialect.
1828    #[test]
1829    fn registration_is_silent_on_a_platform_reached_under_its_own_name() {
1830        let mut registry = crate::dialect::PlatformRegistry::new();
1831        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1832
1833        let mut findings = Vec::new();
1834        check_registration(&HealthyPlugin::new().info(), &registry, &mut findings);
1835        assert!(findings.is_empty(), "{findings:?}");
1836
1837        for spelling in ["healthydb", "HealthyDB", "  healthydb  "] {
1838            assert!(
1839                crate::resolve::resolve_dialect_by_name_with_registry(spelling, &registry).is_ok(),
1840                "'{spelling}' is the same platform"
1841            );
1842        }
1843        assert!(
1844            crate::resolve::resolve_dialect_by_name_with_registry("nosuchdb", &registry).is_err()
1845        );
1846    }
1847
1848    /// **A dialect the host loaded at run time can be graded at all.** [`check`]
1849    /// asks the dialects linked into this program, which one loaded from a
1850    /// shared library is not among; [`check_with_registry`] asks the registry it
1851    /// was loaded into, and the registration judgement is answered from there.
1852    #[test]
1853    fn a_dialect_the_host_loaded_reports_clean_against_its_own_registry() {
1854        let mut registry = crate::dialect::PlatformRegistry::new();
1855        registry.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1856
1857        let report = check_with_registry(&HealthyPlugin::new(), &registry);
1858        assert!(
1859            report.is_clean(),
1860            "a loaded dialect that answered everything owes nothing:\n{report}"
1861        );
1862    }
1863
1864    /// **Every fix an outside author can read is a fix in their own crate.** A
1865    /// report that names a file inside Razeri is telling an author to edit a
1866    /// tree they do not have, and the fix cannot be performed at all.
1867    #[test]
1868    fn no_fix_line_sends_an_author_into_razeris_own_tree() {
1869        let mut collided = crate::dialect::PlatformRegistry::new();
1870        collided.register("healthydb".to_owned(), std::sync::Arc::new(HealthyPlugin::new()));
1871        collided.register("twindb".to_owned(), std::sync::Arc::new(Twin));
1872
1873        let mut fixes: Vec<String> = Vec::new();
1874        for report in [
1875            check(&BrokenPlugin),
1876            check(&HealthyPlugin::new()),
1877            check(&OutsidePlugin::new()),
1878            // The collision and the unregistered-name judgements, which are the
1879            // two registration findings an author can actually reach.
1880            check_with_registry(&Twin, &collided),
1881            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1882        ] {
1883            fixes.extend(report.findings.iter().map(|f| f.fix.clone()));
1884        }
1885        assert!(fixes.len() >= 10, "the sweep reached the findings: {}", fixes.len());
1886        for fix in &fixes {
1887            for path in ["schemas/", "crates/", ".json", "scripts/", "razeri-design"] {
1888                assert!(
1889                    !fix.contains(path),
1890                    "a fix line names `{path}`, which is not in the author's crate:\n  {fix}"
1891                );
1892            }
1893        }
1894    }
1895
1896    /// **No finding prints a run of spaces in the middle of a sentence.** A
1897    /// string literal broken across source lines without a `\` keeps the next
1898    /// line's indentation, and the report then reads "the same as a", a run of
1899    /// eighteen spaces, then "column". This reaches every finding the fixtures
1900    /// can reach, plus the four that only a dialect declining every column
1901    /// change and spelling its own routine grammar can.
1902    #[test]
1903    fn no_finding_holds_a_run_of_spaces() {
1904        struct Declining;
1905        impl SqlRenderer for Declining {
1906            fn render_data_type(&self, dt: &DataType) -> String {
1907                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
1908            }
1909            fn quote_identifier(&self, name: &str) -> String {
1910                format!("\"{name}\"")
1911            }
1912            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
1913                name.to_owned()
1914            }
1915            fn render_current_timestamp(&self) -> String {
1916                "CURRENT_TIMESTAMP".to_owned()
1917            }
1918            fn procedure_form(&self) -> ProcedureForm {
1919                ProcedureForm::Custom("Declining PSQL")
1920            }
1921        }
1922        impl ExpressionRenderer for Declining {}
1923        impl DdlRenderer for Declining {
1924            fn render_column_evolution(&self, _change: &ColumnEvolution<'_>) -> Option<String> {
1925                None
1926            }
1927        }
1928        impl DialectCapabilities for Declining {
1929            fn evolution_answers(&self) -> razeri_plugin_contract::EvolutionAnswers {
1930                razeri_plugin_contract::EvolutionAnswers::widening_a_string_in_place()
1931                    .and_a_nested_field()
1932            }
1933        }
1934        impl DmlRenderer for Declining {}
1935        impl ProcedureRenderer for Declining {}
1936        impl FormulaRenderer for Declining {}
1937        impl TaskRenderer for Declining {}
1938        impl ReplicationRenderer for Declining {}
1939        impl StorageRenderer for Declining {}
1940        impl PlatformDialect for Declining {
1941            fn info(&self) -> DialectInfo {
1942                DialectInfo {
1943                    name: "decliningdb",
1944                    display_name: "Declining DB",
1945                    aliases: &[],
1946                    dialect_family: DialectFamily::Custom("decliningdb"),
1947                    family: "Declining",
1948                    is_cloud_native: false,
1949                    is_olap: false,
1950                    is_postgres_compatible: false,
1951                    versions: &[],
1952                    deploy_script: None,
1953                }
1954            }
1955        }
1956
1957        let mut findings = Vec::new();
1958        check_evolution(&Declining, &mut findings);
1959        check_parameter_type_spelling(&Declining, &mut findings);
1960        assert_eq!(findings.len(), 4, "every column change and the parameter note: {findings:?}");
1961
1962        for report in [
1963            check(&BrokenPlugin),
1964            check(&HealthyPlugin::new()),
1965            check(&OutsidePlugin::new()),
1966            check(&Declining),
1967            check_with_registry(&Twin, &crate::dialect::PlatformRegistry::new()),
1968        ] {
1969            findings.extend(report.findings);
1970        }
1971        for finding in &findings {
1972            for text in [&finding.what, &finding.fix] {
1973                assert!(!text.contains("  "), "a finding holds a run of spaces:\n  {text:?}");
1974            }
1975        }
1976    }
1977
1978    /// **A dialect that claims its own grammar and does not spell it is
1979    /// blocking**, and the finding names the method it owes.
1980    ///
1981    /// `Custom` is a promise that the dialect renders the routine itself, and
1982    /// Razeri keeps its side by writing nothing: no near-neighbour grammar,
1983    /// which fails on deploy, and no refusal, which would tell an author their
1984    /// engine has no routines when it has a whole language. A promise with
1985    /// nothing behind it is therefore a build that stops, and it has to be
1986    /// findable before the deploy rather than during it.
1987    #[test]
1988    fn a_custom_grammar_without_a_render_method_is_blocking_on_all_three_kinds() {
1989        for (kind, form_method, render_method) in [
1990            (RoutineKind::Procedure, "procedure_form", "render_procedure"),
1991            (RoutineKind::ScalarFunction, "scalar_function_form", "render_scalar_function"),
1992            (RoutineKind::TableFunction, "table_function_form", "render_table_function"),
1993        ] {
1994            let mut promised = Vec::new();
1995            check_custom_grammar(
1996                CustomGrammar {
1997                    kind,
1998                    grammar: "PL/SQL",
1999                    form_method,
2000                    render_method,
2001                    noun: "routines",
2002                },
2003                |_| None,
2004                &mut promised,
2005            );
2006            assert_eq!(promised.len(), 1, "{form_method}: {promised:?}");
2007            assert_eq!(promised[0].severity, Severity::Blocking, "{promised:?}");
2008            assert!(promised[0].what.contains(form_method), "{promised:?}");
2009            assert!(promised[0].what.contains("PL/SQL"), "{promised:?}");
2010            assert!(promised[0].fix.contains(render_method), "{promised:?}");
2011
2012            // The same claim, kept: nothing to report.
2013            let mut kept = Vec::new();
2014            check_custom_grammar(
2015                CustomGrammar {
2016                    kind,
2017                    grammar: "PL/SQL",
2018                    form_method,
2019                    render_method,
2020                    noun: "routines",
2021                },
2022                |spec| Some(format!("CREATE {} …", spec.name)),
2023                &mut kept,
2024            );
2025            assert!(kept.is_empty(), "{form_method}: a kept promise is not a finding: {kept:?}");
2026        }
2027    }
2028
2029    /// **The two answers about stored procedures have to agree.** A dialect
2030    /// that writes its own procedures and leaves the capability at its default
2031    /// `false` gets no load written as one; a dialect that says its engine has
2032    /// them and refuses to write one says both things at once.
2033    #[test]
2034    fn a_procedure_form_and_the_procedure_capability_must_agree() {
2035        fn check_forms(dialect: &dyn PlatformDialect) -> Report {
2036            let mut findings = Vec::new();
2037            check_routine_forms(dialect, &mut findings);
2038            Report { dialect: "claimsdb".to_owned(), findings }
2039        }
2040
2041        /// `CUSTOM`: the form spells its own grammar, or refuses. `HAS`: the
2042        /// capability's answer.
2043        struct Claims<const CUSTOM: bool, const HAS: bool>;
2044        impl<const CUSTOM: bool, const HAS: bool> SqlRenderer for Claims<CUSTOM, HAS> {
2045            fn render_data_type(&self, dt: &DataType) -> String {
2046                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2047            }
2048            fn quote_identifier(&self, name: &str) -> String {
2049                format!("\"{name}\"")
2050            }
2051            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2052                self.quote_identifier(name)
2053            }
2054            fn render_current_timestamp(&self) -> String {
2055                "CURRENT_TIMESTAMP".to_owned()
2056            }
2057            fn procedure_form(&self) -> ProcedureForm {
2058                if CUSTOM {
2059                    ProcedureForm::Custom("Firebird PSQL")
2060                } else {
2061                    ProcedureForm::Unsupported(
2062                        "This engine has no stored procedures; schedule the load as a query.",
2063                    )
2064                }
2065            }
2066            fn render_procedure(&self, spec: &RoutineSpec) -> Option<String> {
2067                Some(format!("CREATE PROCEDURE {} AS BEGIN END", spec.name))
2068            }
2069        }
2070        impl<const CUSTOM: bool, const HAS: bool> ExpressionRenderer for Claims<CUSTOM, HAS> {}
2071        impl<const CUSTOM: bool, const HAS: bool> DdlRenderer for Claims<CUSTOM, HAS> {}
2072        impl<const CUSTOM: bool, const HAS: bool> DmlRenderer for Claims<CUSTOM, HAS> {}
2073        impl<const CUSTOM: bool, const HAS: bool> ProcedureRenderer for Claims<CUSTOM, HAS> {}
2074        impl<const CUSTOM: bool, const HAS: bool> FormulaRenderer for Claims<CUSTOM, HAS> {}
2075        impl<const CUSTOM: bool, const HAS: bool> TaskRenderer for Claims<CUSTOM, HAS> {}
2076        impl<const CUSTOM: bool, const HAS: bool> ReplicationRenderer for Claims<CUSTOM, HAS> {}
2077        impl<const CUSTOM: bool, const HAS: bool> StorageRenderer for Claims<CUSTOM, HAS> {}
2078        impl<const CUSTOM: bool, const HAS: bool> DialectCapabilities for Claims<CUSTOM, HAS> {
2079            fn supports_stored_procedures(&self) -> bool {
2080                HAS
2081            }
2082        }
2083        impl<const CUSTOM: bool, const HAS: bool> PlatformDialect for Claims<CUSTOM, HAS> {
2084            fn info(&self) -> DialectInfo {
2085                DialectInfo {
2086                    name: "claimsdb",
2087                    display_name: "Claims DB",
2088                    aliases: &[],
2089                    dialect_family: DialectFamily::Custom("claimsdb"),
2090                    family: "Claims",
2091                    is_cloud_native: false,
2092                    is_olap: false,
2093                    is_postgres_compatible: false,
2094                    versions: &[],
2095                    deploy_script: None,
2096                }
2097            }
2098        }
2099
2100        let disagreement = |report: &Report| {
2101            report
2102                .findings
2103                .iter()
2104                .filter(|finding| {
2105                    finding.severity == Severity::Blocking
2106                        && (finding.what.contains("says its engine has none")
2107                            || finding.what.contains("refuses to write one"))
2108                })
2109                .count()
2110        };
2111        let mut findings = Vec::new();
2112        check_routine_forms(&Claims::<true, false>, &mut findings);
2113        let report = Report { dialect: "claimsdb".to_owned(), findings };
2114        assert_eq!(disagreement(&report), 1, "{report}");
2115        assert!(report.to_string().contains("supports_stored_procedures"), "{report}");
2116
2117        for (agrees, label) in [
2118            (check_forms(&Claims::<true, true>), "spelled and used"),
2119            (check_forms(&Claims::<false, false>), "refused and absent"),
2120        ] {
2121            assert_eq!(disagreement(&agrees), 0, "{label}: {agrees}");
2122        }
2123        assert_eq!(disagreement(&check_forms(&Claims::<false, true>)), 1, "refused and claimed");
2124    }
2125
2126    /// **A render method that answers with nothing at all is the same defect.**
2127    ///
2128    /// An override that returns an empty string is a routine razeri would emit
2129    /// as blank space in the middle of a schema, which is worse than an error:
2130    /// nothing in the deploy says anything is missing.
2131    #[test]
2132    fn a_custom_render_method_that_returns_nothing_is_blocking() {
2133        let mut findings = Vec::new();
2134        check_custom_grammar(
2135            CustomGrammar {
2136                kind: RoutineKind::Procedure,
2137                grammar: "PL/SQL",
2138                form_method: "procedure_form",
2139                render_method: "render_procedure",
2140                noun: "procedures",
2141            },
2142            |_| Some("   \n".to_owned()),
2143            &mut findings,
2144        );
2145        assert_eq!(findings.len(), 1, "{findings:?}");
2146        assert_eq!(findings[0].severity, Severity::Blocking, "{findings:?}");
2147    }
2148
2149    /// **A `Custom` grammar with no name gives a reader nothing to read.**
2150    #[test]
2151    fn a_custom_grammar_with_no_name_is_blocking() {
2152        let mut findings = Vec::new();
2153        check_custom_grammar(
2154            CustomGrammar {
2155                kind: RoutineKind::ScalarFunction,
2156                grammar: "  ",
2157                form_method: "scalar_function_form",
2158                render_method: "render_scalar_function",
2159                noun: "scalar functions",
2160            },
2161            |spec| Some(format!("CREATE FUNCTION {}", spec.name)),
2162            &mut findings,
2163        );
2164        assert_eq!(findings.len(), 1, "{findings:?}");
2165        assert!(findings[0].what.contains("names no grammar"), "{findings:?}");
2166    }
2167
2168    /// **A stated fact that is empty, and a stated fact that ignores its
2169    /// argument.**
2170    ///
2171    /// Both answers deploy without an error and are wrong in the data: an empty
2172    /// helper body keys every row to nothing, and a file relation that drops
2173    /// its path reads one file for every entity a design binds. Neither is
2174    /// visible anywhere else, which is why the kit grades them.
2175    #[test]
2176    fn a_stated_key_body_that_is_blank_and_a_file_relation_that_drops_its_path_are_blocking() {
2177        struct Overstated;
2178        impl SqlRenderer for Overstated {
2179            fn render_data_type(&self, dt: &DataType) -> String {
2180                razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
2181            }
2182            fn quote_identifier(&self, name: &str) -> String {
2183                format!("\"{name}\"")
2184            }
2185            fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
2186                name.to_owned()
2187            }
2188            fn render_current_timestamp(&self) -> String {
2189                "CURRENT_TIMESTAMP".to_owned()
2190            }
2191            fn dialect_name(&self) -> &'static str {
2192                "overstateddb"
2193            }
2194            fn sid_helper_body(&self) -> Option<razeri_plugin_contract::SidHelperBody> {
2195                Some(razeri_plugin_contract::SidHelperBody {
2196                    language: razeri_plugin_contract::SidHelperLanguage::JavaScript,
2197                    body: "   \n  ",
2198                })
2199            }
2200            fn delimited_file_relation(&self, _path_literal: &str) -> Option<String> {
2201                Some("read_csv_auto('/tmp/whatever.csv')".to_owned())
2202            }
2203        }
2204        impl ExpressionRenderer for Overstated {}
2205        impl DdlRenderer for Overstated {}
2206        impl DmlRenderer for Overstated {}
2207        impl ProcedureRenderer for Overstated {}
2208        impl FormulaRenderer for Overstated {}
2209        impl TaskRenderer for Overstated {}
2210        impl ReplicationRenderer for Overstated {}
2211        impl StorageRenderer for Overstated {}
2212        impl DialectCapabilities for Overstated {}
2213        impl PlatformDialect for Overstated {
2214            fn info(&self) -> DialectInfo {
2215                DialectInfo {
2216                    name: "overstateddb",
2217                    display_name: "Overstated DB",
2218                    aliases: &[],
2219                    dialect_family: DialectFamily::Custom(""),
2220                    family: "Overstated",
2221                    is_cloud_native: false,
2222                    is_olap: false,
2223                    is_postgres_compatible: false,
2224                    versions: &[],
2225                    deploy_script: None,
2226                }
2227            }
2228        }
2229
2230        let report = check(&Overstated);
2231        for (fragment, why) in [
2232            ("supplies an empty one", "a blank helper body"),
2233            ("does not carry the path", "a file relation that drops its path"),
2234        ] {
2235            let found = report
2236                .findings
2237                .iter()
2238                .find(|f| f.what.contains(fragment))
2239                .unwrap_or_else(|| panic!("{why} is not graded:\n{report}"));
2240            assert_eq!(found.severity, Severity::Blocking, "{why}: {found:?}");
2241        }
2242
2243        // And silence is not a finding. The fully-answered fixture states
2244        // neither fact — the majority answer — and owes nothing for it.
2245        let quiet = check(&HealthyPlugin::new());
2246        for fragment in ["supplies an empty one", "does not carry the path"] {
2247            assert!(
2248                !quiet.findings.iter().any(|f| f.what.contains(fragment)),
2249                "silence is the default and owes nothing:\n{quiet}"
2250            );
2251        }
2252    }
2253
2254    /// **A stated refusal is a claim, and a claim owes the reader words** — the
2255    /// same grading every other refusal gets, because these words are the only
2256    /// thing an operator finds where the load would have been.
2257    #[test]
2258    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
2259        let mut mute = Vec::new();
2260        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
2261        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
2262        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
2263
2264        let silence = check(&BrokenPlugin);
2265        assert!(
2266            silence
2267                .findings
2268                .iter()
2269                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
2270            "a dialect that never answered is unanswered, not wrong:\n{silence}"
2271        );
2272        assert!(
2273            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
2274            "the fix line names the way to state a measured refusal:\n{silence}"
2275        );
2276    }
2277
2278    /// The report leads with what to fix and says it in words a reader can
2279    /// act on — no identifiers from Razeri's own record-keeping, and a
2280    /// one-line count before the detail.
2281    #[test]
2282    fn the_report_reads_as_a_summary_then_a_fix_list() {
2283        let text = check(&BrokenPlugin).to_string();
2284        let first = text.lines().next().expect("a summary line");
2285        assert!(first.starts_with("brokendb:"), "{first}");
2286        assert!(first.contains("to fix"), "{first}");
2287        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
2288    }
2289}